diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 34bfca4..9848ecc 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,23 +5,16 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ cooldown:
+ default-days: 14
groups:
everything:
patterns:
- "*"
- package-ecosystem: npm
+ target-branch: develop
directory: "/"
schedule:
interval: "weekly"
cooldown:
- default-days: 15
- groups:
- dev-dependencies:
- dependency-type: "development"
- update-types:
- - "minor"
- - "patch"
- prod-dependencies:
- dependency-type: "production"
- update-types:
- - "patch"
+ default-days: 14
diff --git a/.github/package.json b/.github/package.json
index f93dbf4..fcb2ded 100644
--- a/.github/package.json
+++ b/.github/package.json
@@ -1,10 +1,12 @@
{
"name": "@datastream/github-workflows",
- "version": "0.5.0",
+ "version": "0.6.1",
"private": true,
+ "type": "module",
"engines": {
- "node": ">=24.0"
+ "node": ">=24"
},
+ "engineStrict": true,
"devDependencies": {
"license-check-and-add": "4.0.5",
"lockfile-lint": "5.0.0"
diff --git a/.github/workflows/ossf-scorecard.yml b/.github/workflows/ossf-scorecard.yml
index f295154..98e1bda 100644
--- a/.github/workflows/ossf-scorecard.yml
+++ b/.github/workflows/ossf-scorecard.yml
@@ -22,6 +22,7 @@ jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
+ timeout-minutes: 15
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
@@ -32,6 +33,11 @@ jobs:
# actions: read
steps:
+ - name: Harden runner
+ uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+ with:
+ egress-policy: audit
+ disable-telemetry: true
- name: "Checkout code"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2800946..cc24a6e 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,10 +13,14 @@ env:
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
build:
name: Build
- if: ${{ github.event.pull_request.merged && github.event.pull_request.head.repo.full_name == github.repository }}
+ if: ${{ github.event.pull_request.merged }}
runs-on: ubuntu-latest
permissions:
@@ -24,8 +28,15 @@ jobs:
id-token: write # actions/attest-build-provenance
attestations: write # actions/attest-build-provenance
steps:
+ - name: Harden runner
+ uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
+ with:
+ egress-policy: audit
+ disable-telemetry: true
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
- name: Setup Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
@@ -35,23 +46,27 @@ jobs:
run: |
tag=$(npm pkg get version | xargs)
echo "tag=${tag}" >> "$GITHUB_ENV"
- echo "prerelease=$([ ${tag##*-*} ] && echo false || echo true)" >> "$GITHUB_ENV"
+ echo "prerelease=$([ "${tag##*-*}" ] && echo false || echo true)" >> "$GITHUB_ENV"
- name: Install dependencies
run: |
npm ci --ignore-scripts
+ - name: Verify dependency signatures
+ run: |
+ npm audit signatures
- name: Build
run: |
npm run build --if-present
- name: Pack
id: pack
run: |
+ mapfile -t args < <(npm query '.workspace:not([private])' | jq -r '.[] | "--workspace", .location')
{
echo "packages<
-
+
-
+
Apache Arrow record batch transform streams.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You can read the documentation at: https://datastream.js.org
+AWS service streams for CloudWatch Logs, DynamoDB, DynamoDB Streams, Kinesis, Lambda, S3, SNS, and SQS.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/aws/client.js b/packages/aws/client.js index 17b665c..f5e2040 100644 --- a/packages/aws/client.js +++ b/packages/aws/client.js @@ -1,11 +1,30 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT + +// AWS regions that expose FIPS 140-2/140-3 validated endpoints. This includes +// the US/Canada commercial regions and BOTH GovCloud regions (which most need +// FIPS and were previously, incorrectly, excluded). +const fipsRegions = new Set([ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "us-gov-east-1", + "us-gov-west-1", +]); + +export const awsRegionSupportsFips = (region) => fipsRegions.has(region); + export const awsClientDefaults = { - useFipsEndpoint: [ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ca-central-1", - ].includes(process.env.AWS_REGION), + // Lazy getter so AWS_REGION is resolved when a client is constructed rather + // than frozen at module-import time (test harnesses / lazy config set it + // after import). + get useFipsEndpoint() { + // Guard the global `process` access so this shared module can also be + // imported in non-node runtimes (browsers/edge) where `process` is + // undefined; there it simply resolves to the non-FIPS default. + return awsRegionSupportsFips(globalThis.process?.env?.AWS_REGION); + }, }; diff --git a/packages/aws/client.test.js b/packages/aws/client.test.js new file mode 100644 index 0000000..13fee9e --- /dev/null +++ b/packages/aws/client.test.js @@ -0,0 +1,110 @@ +import { deepStrictEqual } from "node:assert"; +import test from "node:test"; + +let variant = "unknown"; +for (const execArgv of process.execArgv) { + const flag = "--conditions="; + if (execArgv.includes(flag)) { + variant = execArgv.replace(flag, ""); + } +} + +// client.js has no build step (shipped as source); import it directly. +const { awsClientDefaults, awsRegionSupportsFips } = await import( + `file://${new URL("./client.js", import.meta.url).pathname}` +); + +test(`${variant}: awsClientDefaults.useFipsEndpoint resolves AWS_REGION lazily`, (_t) => { + const original = process.env.AWS_REGION; + try { + process.env.AWS_REGION = "eu-west-1"; + deepStrictEqual(awsClientDefaults.useFipsEndpoint, false); + + // Changing AWS_REGION after import must be reflected (not frozen at import) + process.env.AWS_REGION = "us-east-1"; + deepStrictEqual(awsClientDefaults.useFipsEndpoint, true); + } finally { + if (original === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = original; + } + } +}); + +test(`${variant}: awsRegionSupportsFips includes GovCloud regions`, (_t) => { + deepStrictEqual(awsRegionSupportsFips("us-gov-east-1"), true); + deepStrictEqual(awsRegionSupportsFips("us-gov-west-1"), true); + deepStrictEqual(awsRegionSupportsFips("ca-central-1"), true); + deepStrictEqual(awsRegionSupportsFips("eu-west-1"), false); + deepStrictEqual(awsRegionSupportsFips(undefined), false); +}); + +// In a browser/edge runtime `process` is undefined; reading process.env +// directly throws ReferenceError at module load / first client construction. +// The getter must tolerate a missing `process` global (web parity). +test(`${variant}: awsClientDefaults.useFipsEndpoint does not throw when process is undefined (browser)`, (_t) => { + const original = globalThis.process; + try { + // Simulate a browser global scope where `process` does not exist. + globalThis.process = undefined; + deepStrictEqual(awsClientDefaults.useFipsEndpoint, false); + } finally { + globalThis.process = original; + } +}); + +// client.js is shipped as raw source (no build step; not an export), so there is +// no client.node.mjs to import. Exercise the same source module to assert the +// full FIPS region set, the lookup function and the lazy getter. +const built = await import( + `file://${new URL("./client.js", import.meta.url).pathname}` +); + +test(`${variant}: built awsRegionSupportsFips matches the exact FIPS region set`, (_t) => { + // Every region that must be FIPS-capable (kills StringLiteral mutants that + // blank an individual region, and the Set/array-declaration mutants). + for (const region of [ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ca-central-1", + "ca-west-1", + "us-gov-east-1", + "us-gov-west-1", + ]) { + deepStrictEqual(built.awsRegionSupportsFips(region), true); + } + // Regions NOT in the set must be false (kills the `() => undefined` mutant, + // which would make every lookup falsy/undefined rather than a real boolean). + deepStrictEqual(built.awsRegionSupportsFips("eu-west-1"), false); + deepStrictEqual(built.awsRegionSupportsFips("ap-southeast-2"), false); + deepStrictEqual(built.awsRegionSupportsFips(undefined), false); +}); + +test(`${variant}: built awsClientDefaults.useFipsEndpoint reflects AWS_REGION lazily`, (_t) => { + const original = process.env.AWS_REGION; + try { + process.env.AWS_REGION = "us-west-1"; + deepStrictEqual(built.awsClientDefaults.useFipsEndpoint, true); + process.env.AWS_REGION = "eu-central-1"; + deepStrictEqual(built.awsClientDefaults.useFipsEndpoint, false); + } finally { + if (original === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = original; + } + } +}); + +test(`${variant}: built awsClientDefaults.useFipsEndpoint tolerates a missing process global`, (_t) => { + const original = globalThis.process; + try { + globalThis.process = undefined; + deepStrictEqual(built.awsClientDefaults.useFipsEndpoint, false); + } finally { + globalThis.process = original; + } +}); diff --git a/packages/aws/cloudwatch-logs.js b/packages/aws/cloudwatch-logs.js index 90c1bb0..46290b3 100644 --- a/packages/aws/cloudwatch-logs.js +++ b/packages/aws/cloudwatch-logs.js @@ -5,6 +5,7 @@ import { FilterLogEventsCommand, GetLogEventsCommand, } from "@aws-sdk/client-cloudwatch-logs"; +import { timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; let client = new CloudWatchLogsClient(awsClientDefaults); @@ -19,7 +20,6 @@ export const awsCloudWatchLogsGetLogEventsStream = async ( const { pollingActive, pollingDelay = 1000, ...cwlOptions } = options; cwlOptions.startFromHead ??= true; async function* command(opts) { - let previousToken; let expectMore = true; while (expectMore) { const response = await client.send(new GetLogEventsCommand(opts), { @@ -29,16 +29,18 @@ export const awsCloudWatchLogsGetLogEventsStream = async ( for (const item of events) { yield item; } - const tokenUnchanged = - response.nextForwardToken === previousToken || - response.nextForwardToken === opts.nextToken; - previousToken = response.nextForwardToken; + // CloudWatch echoes the token you sent (opts.nextToken) back as + // nextForwardToken once there are no further events, so an unchanged + // token is the end-of-page / caught-up signal. + const tokenUnchanged = response.nextForwardToken === opts.nextToken; opts.nextToken = response.nextForwardToken; if (tokenUnchanged) { if (pollingActive) { if (pollingDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + // Abortable idle wait: rejects immediately and clears the + // timer when streamOptions.signal aborts mid-delay. + await timeout(pollingDelay, { signal: streamOptions.signal }); } } else { expectMore = false; diff --git a/packages/aws/cloudwatch-logs.test.js b/packages/aws/cloudwatch-logs.test.js index 365c8a7..dd4ec33 100644 --- a/packages/aws/cloudwatch-logs.test.js +++ b/packages/aws/cloudwatch-logs.test.js @@ -1,11 +1,11 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { CloudWatchLogsClient, FilterLogEventsCommand, GetLogEventsCommand, } from "@aws-sdk/client-cloudwatch-logs"; -import { +import cwlDefault, { awsCloudWatchLogsFilterLogEventsStream, awsCloudWatchLogsGetLogEventsStream, awsCloudWatchLogsSetClient, @@ -124,6 +124,7 @@ test(`${variant}: awsCloudWatchLogsGetLogEventsStream should delay polling when client .on(GetLogEventsCommand) .resolvesOnce({ events: [], nextForwardToken: "token1" }) + .resolvesOnce({ events: [], nextForwardToken: "token1" }) .resolvesOnce({ events: [{ message: "log1" }], nextForwardToken: "token2" }) .resolves({ events: [], nextForwardToken: "token2" }); @@ -151,6 +152,41 @@ test(`${variant}: awsCloudWatchLogsGetLogEventsStream should delay polling when deepStrictEqual(output, [{ message: "log1" }]); }); +test(`${variant}: awsCloudWatchLogsGetLogEventsStream should abort the idle poll delay on signal`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + + // Same token every call => tokenUnchanged => enters the idle poll delay. + client.on(GetLogEventsCommand).resolves({ + events: [], + nextForwardToken: "same-token", + }); + + const controller = new AbortController(); + const options = { + logGroupName: "/test/group", + logStreamName: "stream1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options, { + signal: controller.signal, + }); + + const consuming = (async () => { + for await (const _item of stream) { + // no events ever arrive + } + })(); + + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + // --- FilterLogEventsStream --- test(`${variant}: awsCloudWatchLogsFilterLogEventsStream should get events`, async (_t) => { @@ -221,3 +257,208 @@ test(`${variant}: awsCloudWatchLogsGetLogEventsStream should pass signal to clie const calls = client.commandCalls(GetLogEventsCommand); deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); + +// *** startFromHead default & options passthrough *** // +test(`${variant}: awsCloudWatchLogsGetLogEventsStream defaults startFromHead to true`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(GetLogEventsCommand) + .resolves({ events: [], nextForwardToken: "t" }); + + const options = { logGroupName: "g", logStreamName: "s" }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + await streamToArray(stream); + + const input = client.commandCalls(GetLogEventsCommand)[0].args[0].input; + // Defaulted via `??=`: a `&&=` mutant would leave it undefined (because + // undefined &&= true stays undefined). + deepStrictEqual(input.startFromHead, true); + // Caller options are passed through to the command. + deepStrictEqual(input.logGroupName, "g"); + deepStrictEqual(input.logStreamName, "s"); +}); + +test(`${variant}: awsCloudWatchLogsGetLogEventsStream keeps an explicit startFromHead=false`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(GetLogEventsCommand) + .resolves({ events: [], nextForwardToken: "t" }); + + const options = { + logGroupName: "g", + logStreamName: "s", + startFromHead: false, + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + await streamToArray(stream); + + // `??=` must NOT overwrite an explicit false. A plain `=` mutant would force + // it back to true. + const input = client.commandCalls(GetLogEventsCommand)[0].args[0].input; + deepStrictEqual(input.startFromHead, false); +}); + +test(`${variant}: awsCloudWatchLogsFilterLogEventsStream passes options through and paginates on nextToken`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(FilterLogEventsCommand) + .resolvesOnce({ events: [{ message: "a" }], nextToken: "n2" }) + .resolvesOnce({ events: [{ message: "b" }] }); + + const options = { logGroupName: "g", filterPattern: "ERROR" }; + const stream = await awsCloudWatchLogsFilterLogEventsStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "a" }, { message: "b" }]); + const calls = client.commandCalls(FilterLogEventsCommand); + // Two calls: pagination continued because the first response had a nextToken + // (kills the `expectMore = !!nextToken` related behavior) and stopped when the + // second had none. + deepStrictEqual(calls.length, 2); + // Options spread through to the command (kills the `command({})` mutant). + deepStrictEqual(calls[0].args[0].input.logGroupName, "g"); + deepStrictEqual(calls[0].args[0].input.filterPattern, "ERROR"); +}); + +test(`${variant}: awsCloudWatchLogsFilterLogEventsStream stops after a single page when no nextToken`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + // No nextToken on the only response => exactly one call. + client.on(FilterLogEventsCommand).resolves({ events: [{ message: "a" }] }); + + const stream = await awsCloudWatchLogsFilterLogEventsStream({ + logGroupName: "g", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "a" }]); + deepStrictEqual(client.commandCalls(FilterLogEventsCommand).length, 1); +}); + +test(`${variant}: awsCloudWatchLogsFilterLogEventsStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client.on(FilterLogEventsCommand).resolves({ events: [{ message: "a" }] }); + + const controller = new AbortController(); + const stream = await awsCloudWatchLogsFilterLogEventsStream( + { logGroupName: "g" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(FilterLogEventsCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +// *** setClient swap *** // +test(`${variant}: awsCloudWatchLogsSetClient routes to the new client`, async (_t) => { + const first = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(first); + first.on(FilterLogEventsCommand).resolves({ events: [] }); + + const second = mockClient(CloudWatchLogsClient); + second.on(FilterLogEventsCommand).resolves({ events: [] }); + awsCloudWatchLogsSetClient(second); + + const stream = await awsCloudWatchLogsFilterLogEventsStream({ + logGroupName: "g", + }); + await streamToArray(stream); + + deepStrictEqual(second.commandCalls(FilterLogEventsCommand).length, 1); + deepStrictEqual(first.commandCalls(FilterLogEventsCommand).length, 0); +}); + +// setClient must STORE the passed client (mockClient hides instance identity via +// prototype patching). A plain stub proves the stored reference is used; a +// `setClient(){}` mutant leaves the prior client in place. +test(`${variant}: awsCloudWatchLogsSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { events: [{ message: "stub" }] }; + }, + }; + awsCloudWatchLogsSetClient(stub); + + const stream = await awsCloudWatchLogsFilterLogEventsStream({ + logGroupName: "g", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// GetLogEvents stops as soon as nextForwardToken equals the token that was sent +// (opts.nextToken), even on the very first call when the caller resumes from a +// known token. Pins `nextForwardToken === opts.nextToken` (a `false`/always-loop +// mutant would never stop; this also kills the unchanged-token guard). +test(`${variant}: awsCloudWatchLogsGetLogEventsStream stops when the echoed token equals the sent token`, async (_t) => { + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + // Caller resumes from "tokenX"; CloudWatch immediately echoes it back -> done. + client.on(GetLogEventsCommand).resolves({ + events: [{ message: "log1" }], + nextForwardToken: "tokenX", + }); + + const options = { + logGroupName: "g", + logStreamName: "s", + nextToken: "tokenX", + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ message: "log1" }]); + // Exactly one call: the echoed token matched the sent token on the first page. + deepStrictEqual(client.commandCalls(GetLogEventsCommand).length, 1); +}); + +// pollingActive with pollingDelay === 0 must NOT schedule a timer: the +// `pollingDelay > 0` guard is false. A `true` or `>= 0` mutant would await +// timeout(0); under un-ticked mock timers the consumer would never reach the +// later (changed-token) event. +test(`${variant}: awsCloudWatchLogsGetLogEventsStream pollingDelay 0 schedules no timer`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(CloudWatchLogsClient); + awsCloudWatchLogsSetClient(client); + client + .on(GetLogEventsCommand) + // caught-up page: token unchanged from the (undefined) sent token? No - + // send a repeated token so tokenUnchanged is true and we enter the polling + // branch, then a fresh token + event without any timer tick. + .resolvesOnce({ events: [], nextForwardToken: "t1" }) + .resolvesOnce({ events: [], nextForwardToken: "t1" }) + .resolvesOnce({ events: [{ message: "log1" }], nextForwardToken: "t2" }) + .resolves({ events: [], nextForwardToken: "t2" }); + + const options = { + logGroupName: "g", + logStreamName: "s", + pollingActive: true, + pollingDelay: 0, + }; + const stream = await awsCloudWatchLogsGetLogEventsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + deepStrictEqual(output, [{ message: "log1" }]); +}); + +test(`${variant}: cloudwatch-logs default export exposes all stream functions`, (_t) => { + deepStrictEqual(Object.keys(cwlDefault).sort(), [ + "filterLogEventsStream", + "getLogEventsStream", + "setClient", + ]); +}); diff --git a/packages/aws/dynamodb-streams.js b/packages/aws/dynamodb-streams.js index a1c219f..cf06704 100644 --- a/packages/aws/dynamodb-streams.js +++ b/packages/aws/dynamodb-streams.js @@ -4,6 +4,7 @@ import { DynamoDBStreamsClient, GetRecordsCommand, } from "@aws-sdk/client-dynamodb-streams"; +import { timeout } from "@datastream/core"; import { awsClientDefaults } from "./client.js"; let client = new DynamoDBStreamsClient(awsClientDefaults); @@ -30,7 +31,9 @@ export const awsDynamoDBStreamsGetRecordsStream = async ( expectMore = opts.ShardIterator !== null && (pollingActive || records.length > 0); if (pollingActive && records.length === 0 && pollingDelay > 0) { - await new Promise((resolve) => setTimeout(resolve, pollingDelay)); + // Abortable idle wait: rejects immediately and clears the timer + // when streamOptions.signal aborts mid-delay. + await timeout(pollingDelay, { signal: streamOptions.signal }); } } } diff --git a/packages/aws/dynamodb-streams.test.js b/packages/aws/dynamodb-streams.test.js index 4af7917..3a05547 100644 --- a/packages/aws/dynamodb-streams.test.js +++ b/packages/aws/dynamodb-streams.test.js @@ -1,10 +1,10 @@ -import { deepStrictEqual } from "node:assert"; +import { deepStrictEqual, rejects } from "node:assert"; import test from "node:test"; import { DynamoDBStreamsClient, GetRecordsCommand, } from "@aws-sdk/client-dynamodb-streams"; -import { +import ddbStreamsDefault, { awsDynamoDBStreamsGetRecordsStream, awsDynamoDBStreamsSetClient, } from "@datastream/aws/dynamodb-streams"; @@ -159,6 +159,38 @@ test(`${variant}: awsDynamoDBStreamsGetRecordsStream should delay polling when p deepStrictEqual(output, [{ eventID: "1" }]); }); +test(`${variant}: awsDynamoDBStreamsGetRecordsStream should abort the idle poll delay on signal`, async (_t) => { + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + + client + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: "i" }); + + const controller = new AbortController(); + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsDynamoDBStreamsGetRecordsStream(options, { + signal: controller.signal, + }); + + const consuming = (async () => { + for await (const _item of stream) { + // no records ever arrive + } + })(); + + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + // *** AbortSignal *** // test(`${variant}: awsDynamoDBStreamsGetRecordsStream should pass signal to client`, async (_t) => { const client = mockClient(DynamoDBStreamsClient); @@ -178,3 +210,152 @@ test(`${variant}: awsDynamoDBStreamsGetRecordsStream should pass signal to clien const calls = client.commandCalls(GetRecordsCommand); deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); }); + +// *** options spread through to the command *** // +test(`${variant}: awsDynamoDBStreamsGetRecordsStream passes Limit through to GetRecords`, async (_t) => { + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [{ eventID: "1" }], NextShardIterator: null }); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "shard-abc", + Limit: 25, + }); + await streamToArray(stream); + + // `opts.ShardIterator` is reassigned inside the loop and captured by + // reference, so assert on `Limit` (not mutated) -- it proves the caller + // options were spread through (a `command({})` mutant would drop it). + const input = client.commandCalls(GetRecordsCommand)[0].args[0].input; + deepStrictEqual(input.Limit, 25); +}); + +// *** setClient swap *** // +test(`${variant}: awsDynamoDBStreamsSetClient routes to the new client`, async (_t) => { + const first = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(first); + first + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: null }); + + const second = mockClient(DynamoDBStreamsClient); + second + .on(GetRecordsCommand) + .resolves({ Records: [], NextShardIterator: null }); + awsDynamoDBStreamsSetClient(second); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "i", + }); + await streamToArray(stream); + + deepStrictEqual(second.commandCalls(GetRecordsCommand).length, 1); + deepStrictEqual(first.commandCalls(GetRecordsCommand).length, 0); +}); + +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place. +test(`${variant}: awsDynamoDBStreamsSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Records: [{ eventID: "stub" }], NextShardIterator: null }; + }, + }; + awsDynamoDBStreamsSetClient(stub); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "iter1", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ eventID: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// *** idle-wait guard conjuncts *** // +test(`${variant}: awsDynamoDBStreamsGetRecordsStream does not delay when records are present`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + // Records always present so the `records.length === 0` conjunct is false and + // no timer is scheduled. A `true` mutant on that conjunct would delay after + // every (non-empty) poll and the consumer would stall on the mock timer. + client.on(GetRecordsCommand).resolves({ + Records: [{ eventID: "1" }], + NextShardIterator: "iter-next", + }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 60_000, + }; + const stream = await awsDynamoDBStreamsGetRecordsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 3) break; + } + deepStrictEqual(output.length, 3); +}); + +test(`${variant}: awsDynamoDBStreamsGetRecordsStream pollingDelay 0 schedules no timer`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + // pollingActive, empty records, pollingDelay === 0: `pollingDelay > 0` is false + // so no idle wait. A `> 0` -> `true` or `>= 0` mutant would await timeout(0) + // which the un-ticked mock timer never resolves. + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [], NextShardIterator: "iter2" }) + .resolves({ Records: [{ eventID: "1" }], NextShardIterator: "iter3" }); + + const options = { + ShardIterator: "iter1", + pollingActive: true, + pollingDelay: 0, + }; + const stream = await awsDynamoDBStreamsGetRecordsStream(options); + + const output = []; + for await (const item of stream) { + output.push(item); + if (output.length >= 1) break; + } + deepStrictEqual(output, [{ eventID: "1" }]); +}); + +test(`${variant}: awsDynamoDBStreamsGetRecordsStream non-polling empty page ends without an idle wait`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const client = mockClient(DynamoDBStreamsClient); + awsDynamoDBStreamsSetClient(client); + // Not polling: an empty page ends the stream WITHOUT entering the idle wait. + // The `&&` -> `||` mutant makes `pollingActive || records.length === 0` true on + // the empty page, scheduling a (default 1000ms) timeout the un-ticked mock + // timer never resolves -> the stream would hang instead of completing. + client + .on(GetRecordsCommand) + .resolvesOnce({ Records: [{ eventID: "1" }], NextShardIterator: "iter2" }) + .resolves({ Records: [], NextShardIterator: "iter3" }); + + const stream = await awsDynamoDBStreamsGetRecordsStream({ + ShardIterator: "iter1", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ eventID: "1" }]); +}); + +test(`${variant}: dynamodb-streams default export exposes all stream functions`, (_t) => { + deepStrictEqual(Object.keys(ddbStreamsDefault).sort(), [ + "getRecordsStream", + "setClient", + ]); +}); diff --git a/packages/aws/dynamodb.js b/packages/aws/dynamodb.js index 5459659..50cf46a 100644 --- a/packages/aws/dynamodb.js +++ b/packages/aws/dynamodb.js @@ -17,6 +17,22 @@ export const awsDynamoDBSetClient = (ddbClient, _translateConfig) => { }; awsDynamoDBSetClient(client); +// UnprocessedItems/UnprocessedKeys are throttling-driven; a near-zero early +// delay (3^0 == 1ms) just hammers the table. Apply a floor so the first retries +// give capacity time to recover, while preserving the ~59sec cap (3^10). +const DYNAMODB_BACKOFF_FLOOR_MS = 50; +const DYNAMODB_BACKOFF_CAP_MS = 3 ** 10; +// streamOptions is always supplied by the callers (getItem / batchWrite, which +// receive the stream's streamOptions defaulting to {}), so it is never nullish. +const dynamodbBackoff = (retryCount, streamOptions) => + timeout( + Math.min( + DYNAMODB_BACKOFF_CAP_MS, + Math.max(DYNAMODB_BACKOFF_FLOOR_MS, 3 ** retryCount), + ), + { signal: streamOptions.signal }, + ); + // options = {TableName, ...} export const awsDynamoDBQueryStream = async (options, streamOptions = {}) => { @@ -26,7 +42,7 @@ export const awsDynamoDBQueryStream = async (options, streamOptions = {}) => { const response = await client.send(new QueryCommand(opts), { abortSignal: streamOptions.signal, }); - for (const item of response.Items) { + for (const item of response.Items ?? []) { yield item; } opts.ExclusiveStartKey = response.LastEvaluatedKey; @@ -43,7 +59,7 @@ export const awsDynamoDBScanStream = async (options, streamOptions = {}) => { const response = await client.send(new ScanCommand(opts), { abortSignal: streamOptions.signal, }); - for (const item of response.Items) { + for (const item of response.Items ?? []) { yield item; } opts.ExclusiveStartKey = response.LastEvaluatedKey; @@ -92,11 +108,11 @@ export const awsDynamoDBGetItemStream = async (options, streamOptions = {}) => { }), { abortSignal: streamOptions.signal }, ); - for (const item of response.Responses[options.TableName]) { + for (const item of response.Responses?.[options.TableName] ?? []) { yield item; } const UnprocessedKeys = - response?.UnprocessedKeys?.[options.TableName]?.Keys ?? []; + response.UnprocessedKeys?.[options.TableName]?.Keys ?? []; if (!UnprocessedKeys.length) { break; } @@ -110,7 +126,7 @@ export const awsDynamoDBGetItemStream = async (options, streamOptions = {}) => { }); } - await timeout(3 ** retryCount, { signal: streamOptions.signal }); // 3^10 == 59sec + await dynamodbBackoff(retryCount, streamOptions); retryCount++; keys = UnprocessedKeys; } @@ -171,7 +187,8 @@ const dynamodbBatchWrite = async ( [options.TableName]: batch, }, }), - { abortSignal: streamOptions?.signal }, + // streamOptions is always supplied by put/delete (defaulting to {}). + { abortSignal: streamOptions.signal }, ); if (UnprocessedItems?.[options.TableName]?.length) { if (retryCount >= retryMaxCount) { @@ -183,7 +200,7 @@ const dynamodbBatchWrite = async ( }); } - await timeout(3 ** retryCount, { signal: streamOptions?.signal }); // 3^10 == 59sec + await dynamodbBackoff(retryCount, streamOptions); return dynamodbBatchWrite( options, UnprocessedItems[options.TableName], diff --git a/packages/aws/dynamodb.test.js b/packages/aws/dynamodb.test.js index cc78a98..a97d56e 100644 --- a/packages/aws/dynamodb.test.js +++ b/packages/aws/dynamodb.test.js @@ -613,6 +613,448 @@ test(`${variant}: awsDynamoDBQueryStream should not mutate caller options`, asyn deepStrictEqual(options, optionsCopy); }); +test(`${variant}: awsDynamoDBGetItemStream should reject when Keys.length exceeds 100`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + const options = { + TableName: "TableName", + Keys: new Array(101).fill({ key: "x" }), + }; + await rejects( + () => awsDynamoDBGetItemStream(options), + /exceeds BatchGetItem limit of 100/, + ); +}); + +// *** empty-page / fully-throttled guards *** // +test(`${variant}: awsDynamoDBQueryStream should handle a page with no Items`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(QueryCommand, { TableName: "TableName" }) + .resolvesOnce({ + // filtered page: no Items but still paginating + Count: 0, + LastEvaluatedKey: { key: "a" }, + }) + .resolvesOnce({ + Items: [{ key: "b", value: 2 }], + }); + + const options = { TableName: "TableName" }; + const stream = await awsDynamoDBQueryStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "b", value: 2 }]); +}); + +test(`${variant}: awsDynamoDBScanStream should handle a page with no Items`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(ScanCommand, { TableName: "TableName" }) + .resolvesOnce({ + Count: 0, + LastEvaluatedKey: { key: "a" }, + }) + .resolvesOnce({ + Items: [{ key: "b", value: 2 }], + }); + + const options = { TableName: "TableName" }; + const stream = await awsDynamoDBScanStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "b", value: 2 }]); +}); + +test(`${variant}: awsDynamoDBGetItemStream should handle fully-throttled batch (empty Responses)`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand, { + RequestItems: { TableName: { Keys: [{ key: "a" }] } }, + }) + .resolvesOnce({ + // entire batch throttled: Responses has no entry for the table + Responses: {}, + UnprocessedKeys: { TableName: { Keys: [{ key: "a" }] } }, + }) + .resolvesOnce({ + Responses: { TableName: [{ key: "a", value: 1 }] }, + UnprocessedKeys: {}, + }); + + const options = { TableName: "TableName", Keys: [{ key: "a" }] }; + const stream = await awsDynamoDBGetItemStream(options); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "a", value: 1 }]); +}); + +// *** backoff is abortable: aborting during the backoff wait rejects *** // +test(`${variant}: awsDynamoDBGetItemStream aborts during retry backoff`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Always throttled so the stream enters the (real-timer) backoff wait. + client.on(BatchGetItemCommand).resolves({ + Responses: {}, + UnprocessedKeys: { TableName: { Keys: [{ key: "a" }] } }, + }); + + const controller = new AbortController(); + const options = { TableName: "TableName", Keys: [{ key: "a" }] }; + const stream = await awsDynamoDBGetItemStream(options, { + signal: controller.signal, + }); + + const consuming = streamToArray(stream); + // Let the first throttled batch settle and enter the backoff timer, then + // abort. The backoff timeout was given `{ signal }`, so it must reject + // promptly. A `{}` mutant (dropping the signal) would let the backoff run to + // completion and keep retrying, so this would hang instead of rejecting. + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + + await rejects(consuming, (error) => { + return error.cause?.code === "AbortError" || /Abort/i.test(error.message); + }); +}); + +// *** backoff floor: the first retry waits at least 50ms (not 3^0 == 1ms) *** // +test(`${variant}: awsDynamoDBGetItemStream first retry backoff is floored at 50ms`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolvesOnce({ + Responses: {}, + UnprocessedKeys: { TableName: { Keys: [{ key: "a" }] } }, + }) + .resolves({ + Responses: { TableName: [{ key: "a", value: 1 }] }, + UnprocessedKeys: {}, + }); + + const options = { TableName: "TableName", Keys: [{ key: "a" }] }; + const stream = await awsDynamoDBGetItemStream(options); + const consuming = streamToArray(stream); + + // Let the first (throttled) batch settle and enter the backoff timer. + await new Promise((resolve) => setImmediate(resolve)); + // 3^0 == 1ms would already fire; the 50ms floor means the retry must still be + // pending at 1ms (kills the Math.max -> Math.min floor mutant and the + // arrow-body removal). + t.mock.timers.tick(1); + await new Promise((resolve) => setImmediate(resolve)); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 1); + + t.mock.timers.tick(49); + const output = await consuming; + deepStrictEqual(output, [{ key: "a", value: 1 }]); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 2); +}); + +// *** setClient swap *** // +test(`${variant}: awsDynamoDBSetClient routes subsequent sends to the new client`, async (_t) => { + const first = mockClient(DynamoDBClient); + awsDynamoDBSetClient(first); + first.on(QueryCommand).resolves({ Items: [{ key: "stale" }] }); + + const second = mockClient(DynamoDBClient); + second.on(QueryCommand).resolves({ Items: [{ key: "fresh" }] }); + awsDynamoDBSetClient(second); + + const stream = await awsDynamoDBQueryStream({ TableName: "T" }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "fresh" }]); + deepStrictEqual(second.commandCalls(QueryCommand).length, 1); + deepStrictEqual(first.commandCalls(QueryCommand).length, 0); +}); + +// setClient must STORE the passed client; a plain stub (prototype-mock-proof) +// proves the stored reference is used. A `setClient(){}` mutant leaves the prior +// client in place. +test(`${variant}: awsDynamoDBSetClient stores the passed client reference`, async (_t) => { + let calls = 0; + const stub = { + send: async () => { + calls++; + return { Items: [{ key: "stub" }] }; + }, + }; + awsDynamoDBSetClient(stub); + + const stream = await awsDynamoDBQueryStream({ TableName: "T" }); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ key: "stub" }]); + deepStrictEqual(calls, 1); +}); + +// *** abortSignal forwarding on each read command (kills the `{}` options mutant) *** // +test(`${variant}: awsDynamoDBQueryStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client.on(QueryCommand).resolves({ Items: [{ key: "a" }] }); + + const controller = new AbortController(); + const stream = await awsDynamoDBQueryStream( + { TableName: "T" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(QueryCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsDynamoDBScanStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client.on(ScanCommand).resolves({ Items: [{ key: "a" }] }); + + const controller = new AbortController(); + const stream = await awsDynamoDBScanStream( + { TableName: "T" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(ScanCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsDynamoDBExecuteStatementStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client.on(ExecuteStatementCommand).resolves({ Items: [{ key: "a" }] }); + + const controller = new AbortController(); + const stream = await awsDynamoDBExecuteStatementStream( + { Statement: "SELECT" }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(ExecuteStatementCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +test(`${variant}: awsDynamoDBGetItemStream forwards abortSignal to client.send`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolves({ Responses: { T: [{ key: "a" }] }, UnprocessedKeys: {} }); + + const controller = new AbortController(); + const stream = await awsDynamoDBGetItemStream( + { TableName: "T", Keys: [{ key: "a" }] }, + { signal: controller.signal }, + ); + await streamToArray(stream); + + const calls = client.commandCalls(BatchGetItemCommand); + deepStrictEqual(calls[0].args[1]?.abortSignal, controller.signal); +}); + +// *** executeStatement yields nothing when the response has no Items field *** // +test(`${variant}: awsDynamoDBExecuteStatementStream yields nothing for a response without Items`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // No Items field at all: the `response.Items ?? []` default must be an EMPTY + // array. A `["Stryker was here"]` mutant would yield that sentinel. + client.on(ExecuteStatementCommand).resolves({}); + + const stream = await awsDynamoDBExecuteStatementStream({ + Statement: "SELECT", + }); + const output = await streamToArray(stream); + + deepStrictEqual(output, []); +}); + +// *** Keys boundary: exactly 100 is allowed (strict `>`) *** // +test(`${variant}: awsDynamoDBGetItemStream allows exactly 100 Keys`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolves({ Responses: { T: [] }, UnprocessedKeys: {} }); + + // Exactly 100 must NOT throw (strict `>`); a `>=` mutant would reject it. + const options = { TableName: "T", Keys: new Array(100).fill({ key: "x" }) }; + const stream = await awsDynamoDBGetItemStream(options); + const output = await streamToArray(stream); + deepStrictEqual(output, []); +}); + +// *** getItem with no Keys (optional chaining on options.Keys) *** // +test(`${variant}: awsDynamoDBGetItemStream tolerates options without Keys`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + client + .on(BatchGetItemCommand) + .resolves({ Responses: { T: [{ key: "a" }] }, UnprocessedKeys: {} }); + + // No Keys property: the `options.Keys?.length > 100` guard must short-circuit + // via the optional chain. A non-optional `options.Keys.length` mutant throws. + const stream = await awsDynamoDBGetItemStream({ TableName: "T" }); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ key: "a" }]); +}); + +// *** getItem tolerates a response missing Responses / UnprocessedKeys *** // +test(`${variant}: awsDynamoDBGetItemStream tolerates a response with neither Responses nor UnprocessedKeys`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Bare response: `response.Responses?.[T]` and `response?.UnprocessedKeys?.[T]?.Keys` + // optional chains must yield [] (no items, no retry). Non-optional mutants + // (`response.Responses[T]`, `response.UnprocessedKeys`, `response?.UnprocessedKeys[T]`) + // would throw a TypeError reading a property of undefined. + client.on(BatchGetItemCommand).resolves({}); + + const stream = await awsDynamoDBGetItemStream({ + TableName: "T", + Keys: [{ key: "a" }], + }); + const output = await streamToArray(stream); + deepStrictEqual(output, []); +}); + +// *** getItem retry accounting: retryMaxCount honoured, error cause populated *** // +test(`${variant}: awsDynamoDBGetItemStream throws after exactly retryMaxCount retries with cause`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Always throttled. retryMaxCount 1: attempt #1 (retryCount 0, 0 >= 1 false -> + // backoff, retryCount -> 1), attempt #2 (1 >= 1 true -> throw). Exactly 2 calls. + // A `>` mutant would need retryCount > 1 (3 calls); a `--` mutant on the + // counter would never reach the cap (retry forever -> timeout). + client.on(BatchGetItemCommand).resolves({ + Responses: {}, + UnprocessedKeys: { T: { Keys: [{ key: "a" }] } }, + }); + + await rejects( + async () => + streamToArray( + await awsDynamoDBGetItemStream({ + TableName: "T", + Keys: [{ key: "a" }], + retryMaxCount: 1, + }), + ), + (error) => { + deepStrictEqual( + error.message, + "awsDynamoDBBatchGetItem has UnprocessedKeys", + ); + // `{}` mutants on the error options / cause would drop these fields. + deepStrictEqual(error.cause.UnprocessedKeysCount, 1); + deepStrictEqual(error.cause.TableName, "T"); + return true; + }, + ); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 2); +}); + +// *** getItem: retryMaxCount ?? 10 must use nullish coalescing (not &&) *** // +test(`${variant}: awsDynamoDBGetItemStream honours an explicit retryMaxCount of 2`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // retryMaxCount 2: `2 ?? 10` === 2 (throw after 3 calls). A `2 && 10` mutant + // yields 10 -> 11 calls (and a huge cumulative backoff -> timeout). Assert the + // exact call count pins the coalescing operator. + client.on(BatchGetItemCommand).resolves({ + Responses: {}, + UnprocessedKeys: { T: { Keys: [{ key: "a" }] } }, + }); + + await rejects( + async () => + streamToArray( + await awsDynamoDBGetItemStream({ + TableName: "T", + Keys: [{ key: "a" }], + retryMaxCount: 2, + }), + ), + { message: "awsDynamoDBBatchGetItem has UnprocessedKeys" }, + ); + deepStrictEqual(client.commandCalls(BatchGetItemCommand).length, 3); +}); + +// *** batchWrite: response with no UnprocessedItems is a success *** // +test(`${variant}: awsDynamoDBPutItemStream treats a response without UnprocessedItems as success`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Bare response (no UnprocessedItems): the `UnprocessedItems?.[T]?.length` + // optional chain must be falsy (no retry). A non-optional mutant throws. + client.on(BatchWriteItemCommand).resolves({}); + + const output = await pipeline([ + createReadableStream([{ key: "a", value: 1 }]), + awsDynamoDBPutItemStream({ TableName: "T" }), + ]); + deepStrictEqual(output, {}); + deepStrictEqual(client.commandCalls(BatchWriteItemCommand).length, 1); +}); + +// *** batchWrite retry accounting: retryMaxCount honoured, counter increments, +// error cause populated, recursion uses retryCount + 1 *** // +test(`${variant}: awsDynamoDBPutItemStream throws after exactly retryMaxCount retries with cause`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // Always returns UnprocessedItems. retryMaxCount 1 -> throw after 2 calls. + // `>` mutant -> 3 calls; `retryCount + 1` -> `- 1` mutant never reaches the cap + // (recurses forever -> timeout). + client.on(BatchWriteItemCommand).resolves({ + UnprocessedItems: { T: [{ PutRequest: { Item: { key: "a" } } }] }, + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ key: "a" }]), + awsDynamoDBPutItemStream({ TableName: "T", retryMaxCount: 1 }), + ]), + (error) => { + deepStrictEqual( + error.message, + "awsDynamoDBBatchWriteItem has UnprocessedItems", + ); + deepStrictEqual(error.cause.UnprocessedItemsCount, 1); + deepStrictEqual(error.cause.TableName, "T"); + return true; + }, + ); + deepStrictEqual(client.commandCalls(BatchWriteItemCommand).length, 2); +}); + +test(`${variant}: awsDynamoDBPutItemStream honours an explicit retryMaxCount of 2`, async (_t) => { + const client = mockClient(DynamoDBClient); + awsDynamoDBSetClient(client); + // retryMaxCount 2: `2 ?? 10` === 2 -> throw after 3 calls. A `2 && 10` mutant + // yields 10 -> many more calls (huge backoff -> timeout). + client.on(BatchWriteItemCommand).resolves({ + UnprocessedItems: { T: [{ PutRequest: { Item: { key: "a" } } }] }, + }); + + await rejects( + () => + pipeline([ + createReadableStream([{ key: "a" }]), + awsDynamoDBPutItemStream({ TableName: "T", retryMaxCount: 2 }), + ]), + { message: "awsDynamoDBBatchWriteItem has UnprocessedItems" }, + ); + deepStrictEqual(client.commandCalls(BatchWriteItemCommand).length, 3); +}); + test(`${variant}: default export should include all stream functions`, (_t) => { deepStrictEqual(Object.keys(dynamodbDefault).sort(), [ "deleteItemStream", diff --git a/packages/aws/glue-schema-registry.d.ts b/packages/aws/glue-schema-registry.d.ts new file mode 100644 index 0000000..99ae352 --- /dev/null +++ b/packages/aws/glue-schema-registry.d.ts @@ -0,0 +1,17 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT + +export interface GlueSchemaVersion { + schemaVersionId: string; + schemaDefinition: string; + dataFormat: string; +} + +export function awsGlueSchemaRegistrySetClient(client: unknown): void; + +export function awsGlueSchemaRegistryResolver(options?: { + client?: unknown; + clientOptions?: RecordBase64 encoding and decoding streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/base64/index.node.js b/packages/base64/index.node.js index 05e9bc4..5dede21 100644 --- a/packages/base64/index.node.js +++ b/packages/base64/index.node.js @@ -10,6 +10,17 @@ const toBuffer = (chunk) => ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength) : Buffer.from(chunk); +// Valid base64 requires length to be a multiple of 4 and only valid alphabet +// chars, with at most 2 trailing '=' padding characters. This rejects short +// fragments like "YQ=" (length 3) and standalone padding like "==" (length 2) +// that Buffer.from leniently accepts but atob() in the Web build rejects. +const VALID_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; +const assertValidBase64 = (s) => { + if (s.length % 4 !== 0 || !VALID_BASE64_RE.test(s)) { + throw new Error(`Invalid base64 string: ${JSON.stringify(s)}`); + } +}; + export const base64EncodeStream = (_options = {}, streamOptions = {}) => { let extra; // Buffer | undefined const transform = (chunk, enqueue) => { @@ -19,14 +30,14 @@ export const base64EncodeStream = (_options = {}, streamOptions = {}) => { extra = undefined; } const remaining = buf.length % 3; + const whole = buf.length - remaining; if (remaining > 0) { - extra = Buffer.from(buf.subarray(buf.length - remaining)); - buf = buf.subarray(0, buf.length - remaining); + extra = Buffer.from(buf.subarray(whole)); } - if (buf.length > 0) enqueue(buf.toString("base64")); + if (whole > 0) enqueue(buf.subarray(0, whole).toString("base64")); }; const flush = (enqueue) => { - if (extra && extra.length > 0) { + if (extra) { enqueue(extra.toString("base64")); } }; @@ -36,21 +47,25 @@ export const base64EncodeStream = (_options = {}, streamOptions = {}) => { export const base64DecodeStream = (_options = {}, streamOptions = {}) => { let extra = ""; const transform = (chunk, enqueue) => { - const str = - typeof chunk === "string" ? chunk : toBuffer(chunk).toString("ascii"); - let s = extra.length > 0 ? extra + str : str; - extra = ""; - const remaining = s.length % 4; - if (remaining > 0) { - extra = s.slice(s.length - remaining); - s = s.slice(0, s.length - remaining); + // Base64's alphabet is pure ASCII, so a string chunk and the ASCII byte + // view of a Buffer/Uint8Array chunk are interchangeable. Normalising every + // chunk through toBuffer().toString("ascii") keeps a single code path. + const str = toBuffer(chunk).toString("ascii"); + const s0 = extra + str; + const remaining = s0.length % 4; + const whole = s0.length - remaining; + extra = s0.slice(whole); + const s = s0.slice(0, whole); + if (s.length > 0) { + assertValidBase64(s); + enqueue(Buffer.from(s, "base64")); } - if (s.length > 0) enqueue(Buffer.from(s, "base64")); }; - const flush = (enqueue) => { - if (extra.length > 0) { - enqueue(Buffer.from(extra, "base64")); - } + const flush = () => { + // Any leftover characters form an incomplete quartet (length 1-3) and can + // never be a valid base64 group, so reject rather than silently drop them. + // assertValidBase64("") is a no-op, so no length guard is needed here. + assertValidBase64(extra); }; return createTransformStream(transform, flush, streamOptions); }; diff --git a/packages/base64/index.test.js b/packages/base64/index.test.js index 67d8719..e88ee32 100644 --- a/packages/base64/index.test.js +++ b/packages/base64/index.test.js @@ -8,6 +8,8 @@ import base64Default, { import { createReadableStream, pipejoin, + pipeline, + streamToArray, streamToBuffer, streamToString, } from "@datastream/core"; @@ -102,14 +104,17 @@ test(`${variant}: base64DecodeStream should decode partial base64`, async (_t) = deepStrictEqual(output, "hello"); }); -// Test decode flush with remaining characters +// Test decode flush with a valid padded quartet held across chunks test(`${variant}: base64DecodeStream should flush remaining characters`, async (_t) => { const input = btoa("ab"); // "YWI=" - const chunks = [input.slice(0, 2)]; // Only send "YW" + // Split so that the first chunk contributes only part of a 4-char group; + // the decoder should buffer "YW" and combine with "I=" in the second chunk + // to form the complete valid quartet "YWI=" before decoding. + const chunks = [input.slice(0, 2), input.slice(2)]; // "YW" + "I=" const streams = [createReadableStream(chunks), base64DecodeStream()]; const output = await streamToBuffer(pipejoin(streams)); - deepStrictEqual(output.byteLength, 1); + deepStrictEqual(output.byteLength, 2); // "ab" is 2 bytes }); // *** Binary correctness *** // @@ -150,3 +155,208 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "encodeStream", ]); }); + +// *** Padding validation parity (HIGH finding) *** // +// These malformed inputs must be REJECTED identically on both Node and Web builds. +// "YQ=" is a 3-char fragment (not a multiple of 4) — Node's Buffer.from is lenient +// but atob() throws; after the fix BOTH builds must throw. +const malformedPaddingCases = [ + { input: "YQ=", label: "3-char group with single pad (YQ=)" }, + { input: "Pw=", label: "3-char group with single pad (Pw=)" }, + { input: "QQ=", label: "3-char group with single pad (QQ=)" }, + { input: "==", label: "padding-only (==)" }, + { input: "AAAA==", label: "length-6 with trailing padding (AAAA==)" }, +]; + +for (const { input, label } of malformedPaddingCases) { + test(`${variant}: base64DecodeStream should reject malformed padding: ${label}`, async (_t) => { + let threw = false; + try { + await pipeline([createReadableStream([input]), base64DecodeStream()]); + } catch (_e) { + threw = true; + } + deepStrictEqual( + threw, + true, + `Expected decode of ${JSON.stringify(input)} to throw`, + ); + }); +} + +// Valid padded inputs that must be ACCEPTED on both builds +const validPaddedCases = [ + { + input: "YQ==", + expected: [0x61], + label: "2-char group double-pad (YQ==) -> 'a'", + }, + { + input: "YWI=", + expected: [0x61, 0x62], + label: "3-char group single-pad (YWI=) -> 'ab'", + }, + { + input: "AAAA", + expected: [0x00, 0x00, 0x00], + label: "unpadded 4-char group (AAAA) -> zeros", + }, +]; + +for (const { input, expected, label } of validPaddedCases) { + test(`${variant}: base64DecodeStream should accept valid padded input: ${label}`, async (_t) => { + const streams = [createReadableStream([input]), base64DecodeStream()]; + const output = await streamToBuffer(pipejoin(streams)); + deepStrictEqual(Array.from(output), expected); + }); +} + +// *** Regex anchor coverage *** // +// Inputs with invalid characters at the START or END (length % 4 == 0) must be rejected. +// Without the leading ^ anchor the regex would match a valid suffix and accept '!AAA'. +// Without the trailing $ anchor the regex would match a valid prefix and accept 'AAA!'. +test(`${variant}: base64DecodeStream should reject input with invalid leading char`, async (_t) => { + let threw = false; + try { + await pipeline([createReadableStream(["!AAA"]), base64DecodeStream()]); + } catch (_e) { + threw = true; + } + deepStrictEqual(threw, true, "Expected decode of '!AAA' to throw"); +}); + +test(`${variant}: base64DecodeStream should reject input with invalid trailing char`, async (_t) => { + let threw = false; + try { + await pipeline([createReadableStream(["AAA!"]), base64DecodeStream()]); + } catch (_e) { + threw = true; + } + deepStrictEqual(threw, true, "Expected decode of 'AAA!' to throw"); +}); + +// *** Error message content *** // +// Verify that the thrown error identifies the bad input string (not an empty message). +test(`${variant}: base64DecodeStream error message should include input and label`, async (_t) => { + let errorMessage = ""; + try { + await pipeline([createReadableStream(["!AAA"]), base64DecodeStream()]); + } catch (e) { + errorMessage = e.message; + } + deepStrictEqual( + errorMessage.includes("Invalid base64 string"), + true, + "Error message must contain 'Invalid base64 string'", + ); + deepStrictEqual( + errorMessage.includes("!AAA"), + true, + "Error message must include the bad input", + ); +}); + +// *** Buffer chunk through decode stream *** // +// Passing a Buffer (non-string) chunk exercises the toBuffer().toString('ascii') branch. +// A StringLiteral mutation that replaces 'ascii' with '' causes ERR_UNKNOWN_ENCODING, +// so the correct code must succeed and produce the expected bytes. +test(`${variant}: base64DecodeStream should decode Buffer chunks correctly`, async (_t) => { + const originalText = "Hello, World!"; + const b64 = Buffer.from(originalText).toString("base64"); + // Pass the base64 string as a Buffer chunk (not a plain string) + const streams = [ + createReadableStream([Buffer.from(b64)]), + base64DecodeStream(), + ]; + const output = await streamToBuffer(pipejoin(streams)); + deepStrictEqual(output.toString(), originalText); +}); + +// *** Line-40 slice mutation: cross-boundary extra tracking *** // +// Split a 16-char base64 string at offset 6 (6 % 4 == 2 → remainder=2). +// The correct code stores only the LAST 2 chars as extra; the mutant stores the full +// 6-char string. On the second chunk the mutant concatenates the wrong prefix, +// decoding different bytes (producing "HelHello World!" instead of "Hello World!"). +test(`${variant}: base64DecodeStream should track extra correctly across non-mod4 boundaries`, async (_t) => { + const originalText = "Hello World!"; + const b64 = Buffer.from(originalText).toString("base64"); // 16 chars + // Split so first chunk length is 6 (6 % 4 == 2, non-zero remainder) + const chunks = [b64.slice(0, 6), b64.slice(6)]; + const streams = [createReadableStream(chunks), base64DecodeStream()]; + const output = await streamToBuffer(pipejoin(streams)); + deepStrictEqual(output.toString(), originalText); +}); + +// *** Per-chunk emission shape (encode) *** // +// streamToArray preserves the discrete chunks each transform/flush enqueues. +// A single 4-byte chunk must emit the 3-byte aligned group from transform and +// the held trailing byte from flush as TWO separate chunks. This pins down the +// `% 3` arithmetic, the remaining/whole guards, and the flush emission so that +// mutants which keep the same concatenation but change the split are killed. +test(`${variant}: base64EncodeStream should emit aligned group then trailing byte as separate chunks`, async (_t) => { + const input = Buffer.from("aaaa"); // 4 bytes -> 4 % 3 = 1 trailing byte + const streams = [createReadableStream([input]), base64EncodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks, [ + Buffer.from("aaa").toString("base64"), // "YWFh" + Buffer.from("a").toString("base64"), // "YQ==" + ]); +}); + +// A 3-byte aligned chunk emits exactly one chunk from transform and nothing +// from flush (extra stays undefined). Kills flush `if (extra) -> true` (which +// would call undefined.toString() and throw) and the encode guards that would +// emit an empty trailing chunk. +test(`${variant}: base64EncodeStream should emit a single chunk for aligned input`, async (_t) => { + const input = Buffer.from("aaa"); // 3 bytes, 3 % 3 = 0 + const streams = [createReadableStream([input]), base64EncodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks, [Buffer.from("aaa").toString("base64")]); +}); + +// A single 1-byte chunk holds the whole byte as extra: transform emits NOTHING +// (whole === 0) and flush emits the encoded byte. Kills `whole > 0 -> true` +// and `whole > 0 -> whole >= 0` (which would emit an empty "" chunk from the +// transform) and flush `if (extra) -> false` (which would drop the byte). +test(`${variant}: base64EncodeStream should emit only from flush for a single byte`, async (_t) => { + const input = Buffer.from("a"); // 1 byte, whole === 0 + const streams = [createReadableStream([input]), base64EncodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks, [Buffer.from("a").toString("base64")]); // ["YQ=="] +}); + +// *** Per-chunk emission shape (decode) *** // +// A short (< 4 char) single chunk holds everything as extra: transform emits +// NOTHING (whole === 0) and the leftover is rejected at flush. Kills the decode +// `s.length > 0 -> true / >= 0` mutants (which would enqueue an empty Buffer). +test(`${variant}: base64DecodeStream should not emit for a sub-quartet chunk`, async (_t) => { + const emitted = []; + let threw = false; + await new Promise((resolve) => { + const source = createReadableStream(["YQ"]); + const decode = base64DecodeStream(); + decode.on("data", (chunk) => emitted.push(chunk)); + decode.on("end", resolve); + decode.on("error", () => { + threw = true; + resolve(); + }); + source.pipe(decode); + }); + // "YQ" is an incomplete quartet: the transform must emit no chunk and the + // flush must reject it. Without the `s.length > 0` guard the transform would + // enqueue an empty Buffer before the flush rejection. + deepStrictEqual(threw, true, "Expected incomplete quartet to be rejected"); + deepStrictEqual(emitted, []); +}); + +// A single aligned 4-char chunk decodes to exactly one Buffer chunk from the +// transform; flush emits nothing (extra empty). Kills the `% 4 -> * 4` and +// `whole = length - remaining -> + remaining` mutants which change the split. +test(`${variant}: base64DecodeStream should emit a single buffer chunk for an aligned quartet`, async (_t) => { + const b64 = Buffer.from("abc").toString("base64"); // "YWJj", 4 chars + const streams = [createReadableStream([b64]), base64DecodeStream()]; + const chunks = await streamToArray(pipejoin(streams)); + deepStrictEqual(chunks.length, 1); + deepStrictEqual(Buffer.concat(chunks).toString(), "abc"); +}); diff --git a/packages/base64/index.web.js b/packages/base64/index.web.js index 9176bbd..ced3913 100644 --- a/packages/base64/index.web.js +++ b/packages/base64/index.web.js @@ -3,6 +3,18 @@ /* global btoa, atob */ import { createTransformStream } from "@datastream/core"; +// Valid base64 requires length to be a multiple of 4 and only valid alphabet +// chars, with at most 2 trailing '=' padding characters. This rejects short +// fragments like "YQ=" (length 3) and standalone padding like "==" (length 2) +// so that the Web build (atob) and the Node build (Buffer.from) behave +// identically on malformed input. +const VALID_BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; +const assertValidBase64 = (s) => { + if (s.length % 4 !== 0 || !VALID_BASE64_RE.test(s)) { + throw new Error(`Invalid base64 string: ${JSON.stringify(s)}`); + } +}; + const utf8Encoder = new TextEncoder(); const toBytes = (chunk) => { @@ -66,10 +78,14 @@ export const base64DecodeStream = (_options = {}, streamOptions = {}) => { extra = s.slice(s.length - remaining); s = s.slice(0, s.length - remaining); } - if (s.length > 0) enqueue(binaryStringToBytes(atob(s))); + if (s.length > 0) { + assertValidBase64(s); + enqueue(binaryStringToBytes(atob(s))); + } }; const flush = (enqueue) => { if (extra.length > 0) { + assertValidBase64(extra); enqueue(binaryStringToBytes(atob(extra))); } }; diff --git a/packages/base64/package.json b/packages/base64/package.json index c543062..62750fb 100644 --- a/packages/base64/package.json +++ b/packages/base64/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/base64", - "version": "0.5.0", + "version": "0.6.1", "description": "Base64 encoding and decoding transform streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/charset/README.md b/packages/charset/README.md index e0ea83c..982999e 100644 --- a/packages/charset/README.md +++ b/packages/charset/README.md @@ -1,6 +1,6 @@Character encoding and decoding streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/charset/decode.node.js b/packages/charset/decode.node.js index 81b9525..135a246 100644 --- a/packages/charset/decode.node.js +++ b/packages/charset/decode.node.js @@ -12,15 +12,20 @@ export const charsetDecodeStream = ({ charset } = {}, streamOptions = {}) => { const conv = iconv.getDecoder(charset); const transform = (chunk, enqueue) => { + // conv.write() always returns a string (never nullish), so a plain + // .length check is enough. The stream is objectMode, so enqueue ignores + // any encoding argument; pass only the chunk. const res = conv.write(chunk); - if (res?.length) { - enqueue(res, "utf8"); + if (res.length) { + enqueue(res); } }; const flush = (enqueue) => { + // conv.end() can return undefined for some decoders (e.g. ISO-8859-1), + // so guard the length read with optional chaining. const res = conv.end(); if (res?.length) { - enqueue(res, "utf8"); + enqueue(res); } }; return createTransformStream(transform, flush, streamOptions); diff --git a/packages/charset/decode.web.js b/packages/charset/decode.web.js index 874e6bb..bbe240a 100644 --- a/packages/charset/decode.web.js +++ b/packages/charset/decode.web.js @@ -2,56 +2,26 @@ // SPDX-License-Identifier: MIT /* global TextDecoderStream */ -const supportedEncodings = new Set([ - "utf-8", - "utf-16le", - "utf-16be", - "ibm866", - "iso-8859-2", - "iso-8859-3", - "iso-8859-4", - "iso-8859-5", - "iso-8859-6", - "iso-8859-7", - "iso-8859-8", - "iso-8859-8-i", - "iso-8859-10", - "iso-8859-13", - "iso-8859-14", - "iso-8859-15", - "iso-8859-16", - "koi8-r", - "koi8-u", - "macintosh", - "windows-874", - "windows-1250", - "windows-1251", - "windows-1252", - "windows-1253", - "windows-1254", - "windows-1255", - "windows-1256", - "windows-1257", - "windows-1258", - "x-mac-cyrillic", - "gbk", - "gb18030", - "big5", - "euc-jp", - "iso-2022-jp", - "shift_jis", - "euc-kr", - "replacement", - "x-user-defined", -]); - export const charsetDecodeStream = ({ charset } = {}, _streamOptions = {}) => { - if (charset !== null && !supportedEncodings.has(charset.toLowerCase())) { + // Default/null charset means UTF-8, matching the node implementation which + // falls back to UTF-8 for unknown/missing encodings instead of crashing. + // `new TextDecoderStream(undefined)` already defaults to UTF-8, so normalise + // null to undefined. + if (charset === null || charset === undefined) { + return new TextDecoderStream(); + } + // Let TextDecoderStream validate the label rather than maintaining a manual + // allowlist that is stricter than the platform (it rejected valid labels such + // as ISO-8859-1 / ISO-8859-9 that TextDecoder accepts). The constructor + // throws a RangeError for genuinely unknown labels; rethrow with the + // package-branded message. + try { + return new TextDecoderStream(charset); + } catch { throw new Error( `charsetDecodeStream: Unsupported web encoding "${charset}"`, ); } - return new TextDecoderStream(charset); }; export default charsetDecodeStream; diff --git a/packages/charset/detect.js b/packages/charset/detect.js index bcd3466..ac78f77 100644 --- a/packages/charset/detect.js +++ b/packages/charset/detect.js @@ -22,40 +22,81 @@ const charsetKeys = [ "ISO-8859-5", "ISO-8859-6", "ISO-8859-7", - "ISO-8859-8-I", "ISO-8859-8", "windows-1251", "windows-1256", "windows-1252", "windows-1254", "windows-1250", - "KOIR8-R", + "KOI8-R", "ISO-8859-9", ]; +// Cap the detection sample so we never buffer an unbounded amount of the +// stream. chardet analyses a representative prefix; 64KB is plenty. +const MAX_DETECTION_SAMPLE = 64 * 1024; + +// chardet reports pure-ASCII input as "ASCII" (often at confidence 100). ASCII +// is a strict subset of UTF-8, so fold an ASCII match into the UTF-8 bucket +// rather than discarding the highest-confidence result and reporting a +// spurious ISO-8859-1 winner. +const normaliseMatchName = (name) => (name === "ASCII" ? "UTF-8" : name); + +// Concatenate the sampled Uint8Array chunks without relying on the node-only +// Buffer global, so the shared detect source runs in the browser too. String +// chunks are encoded as UTF-8 bytes via TextEncoder for the same reason. +const concatBytes = (chunks, totalLength) => { + const out = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +}; + export const charsetDetectStream = ({ resultKey } = {}, streamOptions = {}) => { - const charsets = Object.fromEntries(charsetKeys.map((k) => [k, 0])); - let chunkCount = 0; + // Accumulate a bounded byte sample and run chardet once on the whole sample + // in result(). Running analyse() per-chunk and averaging corrupts results at + // multibyte chunk boundaries (a split sequence mis-detects each fragment). + const sample = []; + let sampleLength = 0; + const encoder = new TextEncoder(); const passThrough = (chunk) => { - const matches = analyse( - typeof chunk === "string" ? Buffer.from(chunk) : chunk, - ); - chunkCount++; - if (matches.length) { - for (const match of matches) { - if (match.name in charsets) { - charsets[match.name] += match.confidence; - } - } - } + const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk; + // Keep only the bytes that still fit under the cap. subarray clamps the + // end index to the array length, so this both passes short chunks through + // whole and truncates the one chunk that crosses MAX_DETECTION_SAMPLE; + // once the cap is reached remaining is 0 and the slice is empty, so no + // further bytes are ever sampled. This single bound makes the cap the + // sole gate (no redundant length guard that a slice would mask). + const remaining = MAX_DETECTION_SAMPLE - sampleLength; + const slice = bytes.subarray(0, remaining); + sample.push(slice); + sampleLength += slice.length; }; const stream = createPassThroughStream(passThrough, streamOptions); stream.result = () => { - const divisor = chunkCount || 1; + // No bytes seen: signal "nothing to detect" rather than a phantom guess so + // callers can distinguish empty input from a low-confidence real result. + if (!sampleLength) { + return { + key: resultKey ?? "charset", + value: { charset: undefined, confidence: 0 }, + }; + } + const charsets = Object.fromEntries(charsetKeys.map((k) => [k, undefined])); + const matches = analyse(concatBytes(sample, sampleLength)); + for (const match of matches) { + const name = normaliseMatchName(match.name); + if (name in charsets) { + charsets[name] = Math.max(charsets[name] ?? 0, match.confidence); + } + } const values = Object.entries(charsets) .map(([charset, confidence]) => ({ charset, - confidence: confidence / divisor, + confidence: confidence ?? 0, })) .sort((a, b) => b.confidence - a.confidence); return { key: resultKey ?? "charset", value: values[0] }; diff --git a/packages/charset/encode.node.js b/packages/charset/encode.node.js index b167dba..6a12ed6 100644 --- a/packages/charset/encode.node.js +++ b/packages/charset/encode.node.js @@ -11,14 +11,21 @@ export const charsetEncodeStream = ({ charset } = {}, streamOptions = {}) => { const conv = iconv.getEncoder(charset); const transform = (chunk, enqueue) => { + // conv.write() always returns a Buffer (never nullish), so a plain + // .length check is enough here. const res = conv.write(chunk); - if (res?.length) { + if (res.length) { enqueue(res); } }; - const flush = () => { - // iconv-lite encoder.end() always returns undefined, so no flush needed - conv.end(); + const flush = (enqueue) => { + // Stateful encoders (e.g. UTF-7-IMAP) buffer multibyte content and emit + // their trailing shift-out sequence only from end(); mirror decode.node.js + // and enqueue that tail so no data is dropped. + const res = conv.end(); + if (res?.length) { + enqueue(res); + } }; return createTransformStream(transform, flush, streamOptions); }; diff --git a/packages/charset/encode.web.js b/packages/charset/encode.web.js index 21dc5c2..d3b7d0f 100644 --- a/packages/charset/encode.web.js +++ b/packages/charset/encode.web.js @@ -2,8 +2,21 @@ // SPDX-License-Identifier: MIT /* global TextEncoderStream */ +// NOTE: web vs node parity caveat. The browser TextEncoder genuinely only +// supports UTF-8, so the web encoder is UTF-8-only and throws for any other +// charset. This is intentionally asymmetric with charsetDecodeStream (web), +// which accepts every label TextDecoder supports (UTF-16, ISO-8859-1, ...), and +// with the node encoder, which supports the full iconv set. A detect->encode +// pipeline that selects a non-UTF-8 charset works under node but throws under +// web; callers targeting the browser must encode to UTF-8. export const charsetEncodeStream = ({ charset } = {}, _streamOptions = {}) => { - if (charset !== null && charset.toUpperCase() !== "UTF-8") { + // Default/null charset means UTF-8, matching the node implementation. Only a + // non-UTF-8 charset is rejected; calling with no charset must not throw. + if ( + charset !== null && + charset !== undefined && + String(charset).toUpperCase() !== "UTF-8" + ) { throw new Error( `charsetEncodeStream: Web only supports UTF-8 encoding, got "${charset}"`, ); diff --git a/packages/charset/index.test.js b/packages/charset/index.test.js index b8bf89e..67e0b17 100644 --- a/packages/charset/index.test.js +++ b/packages/charset/index.test.js @@ -13,6 +13,7 @@ import { streamToArray, streamToString, } from "@datastream/core"; +import iconv from "iconv-lite"; let variant = "unknown"; for (const execArgv of process.execArgv) { @@ -262,6 +263,25 @@ test(`${variant}: charsetDecodeStream should handle unsupported charset`, async deepStrictEqual(output, "test"); }); +// *** stateful-encoder flush: encoders such as UTF-7-IMAP buffer multibyte +// content and emit the trailing shift sequence ONLY from end(); the flush +// handler must enqueue conv.end()'s return value or that tail is lost. *** // +test(`${variant}: charsetEncodeStream should flush trailing bytes of a stateful encoder (UTF-7-IMAP)`, async (_t) => { + const original = "Hello 世界"; + const streams = [ + createReadableStream([original]), + charsetEncodeStream({ charset: "UTF-7-IMAP" }), + ]; + + const stream = pipejoin(streams); + const output = await streamToArray(stream); + const encoded = Buffer.concat(output.map((c) => Buffer.from(c))); + + // Round-trip through iconv to confirm the full multibyte content survived. + const decoded = iconv.decode(encoded, "UTF-7-IMAP"); + deepStrictEqual(decoded, original); +}); + test(`${variant}: charsetEncodeStream should handle ISO-8859-8-I charset`, async (_t) => { const input = ["test"]; const streams = [ @@ -324,6 +344,37 @@ test(`${variant}: charsetDetectStream should identify UTF-8 for multibyte input` ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); }); +// *** ascii-classification: pure ASCII text (the most common input) must NOT +// be misreported as a high-confidence ISO-8859-1; ASCII is UTF-8-compatible +// so it should fold into UTF-8 rather than being dropped. *** // +test(`${variant}: charsetDetectStream should classify pure ASCII text as UTF-8`, async (_t) => { + for (const text of ["Hello World", "Test content", "abc", "1234567890"]) { + const streams = [ + createReadableStream([Buffer.from(text)]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual( + value.charset, + "UTF-8", + `ASCII text ${JSON.stringify(text)} should classify as UTF-8, got ${value.charset}@${value.confidence}`, + ); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); + } +}); + +// *** empty-input sentinel: an empty stream ran detection on zero bytes; the +// result must signal "nothing to detect" rather than a phantom UTF-8 guess. *** // +test(`${variant}: charsetDetectStream should signal unknown for empty input`, async (_t) => { + const streams = [createReadableStream([]), charsetDetectStream()]; + await pipeline(streams); + const { key, value } = streams[1].result(); + strictEqual(key, "charset"); + strictEqual(value.charset, undefined); + strictEqual(value.confidence, 0); +}); + // *** charsetDetectStream concurrent isolation regression *** // test(`${variant}: charsetDetectStream instances should not share state`, async (_t) => { const input1 = [Buffer.from("Hello World")]; @@ -386,3 +437,767 @@ if (variant === "webstream") { } }); } + +// *** detect-per-chunk-analyse: detection must be accurate across multibyte +// chunk boundaries (buffer for detection, do not average per-chunk) *** // +test(`${variant}: charsetDetectStream should detect UTF-8 when a multibyte char is split across chunks`, async (_t) => { + // A 3-byte UTF-8 character split mid-sequence so the first chunk is a single + // leading byte. Per-chunk analysis mis-detects the fragments (chunk1 -> + // UTF-32LE, chunk2 -> Shift_JIS) and averaging flips the winner away from + // UTF-8; whole-sample analysis must still confidently report UTF-8. + const full = Buffer.from("テスト", "utf8"); + const chunk1 = full.subarray(0, 1); + const chunk2 = full.subarray(1); + + const streams = [ + createReadableStream([chunk1, chunk2]), + charsetDetectStream(), + ]; + + await pipeline(streams); + const { value } = streams[1].result(); + + strictEqual(value.charset, "UTF-8"); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); +}); + +// *** koi8r-key-typo: KOI8-R encoded Cyrillic must be detected as KOI8-R +// (the accumulator key must match chardet's output name) *** // +test(`${variant}: charsetDetectStream should detect KOI8-R Cyrillic text`, async (_t) => { + // "Привет, как дела? ..." encoded as KOI8-R (single-byte Cyrillic). + // Byte sequence produced by iconv-lite koi8-r encode of the Russian text. + const koi8rBytes = Buffer.from([ + 0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcb, 0xc1, 0xcb, 0x20, + 0xc4, 0xc5, 0xcc, 0xc1, 0x3f, 0x20, 0xfa, 0xd4, 0xcf, 0x20, 0xd4, 0xc5, + 0xd3, 0xd4, 0xcf, 0xd7, 0xd9, 0xca, 0x20, 0xd4, 0xc5, 0xcb, 0xd3, 0xd4, + 0x20, 0xce, 0xc1, 0x20, 0xd2, 0xd5, 0xd3, 0xd3, 0xcb, 0xcf, 0xcd, 0x20, + 0xd1, 0xda, 0xd9, 0xcb, 0xc5, 0x2e, + ]); + const streams = [createReadableStream([koi8rBytes]), charsetDetectStream()]; + + await pipeline(streams); + const { value } = streams[1].result(); + + strictEqual(value.charset, "KOI8-R"); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); +}); + +// *** web-specific fixes: import the web sources directly. A bare +// "@datastream/charset" import always resolves to the node build, so the +// *.web.js code paths are exercised by importing them by file URL. *** // +const decodeWeb = await import( + `file://${new URL("./decode.web.js", import.meta.url).pathname}` +); +const encodeWeb = await import( + `file://${new URL("./encode.web.js", import.meta.url).pathname}` +); +// detect.js is a single cross-platform source built for both node and web. To +// prove it is web-safe we import the source directly and run it with the +// node-only `Buffer` global removed, simulating a browser runtime. +const detectSource = await import( + `file://${new URL("./detect.js", import.meta.url).pathname}` +); + +const webStreamToString = async (stream, inputs) => { + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + const collected = []; + const pump = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + collected.push(value); + } + })(); + for (const input of inputs) { + await writer.write(input); + } + await writer.close(); + await pump; + return collected.join(""); +}; + +const webStreamToBytes = async (stream, inputs) => { + const reader = stream.readable.getReader(); + const writer = stream.writable.getWriter(); + const collected = []; + const pump = (async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + collected.push(value); + } + })(); + for (const input of inputs) { + await writer.write(input); + } + await writer.close(); + await pump; + return Buffer.concat(collected.map((c) => Buffer.from(c))); +}; + +// *** web-default-charset-crash: no-arg / null charset must fall back to +// UTF-8 like node, not throw *** // +test(`${variant}: web charsetDecodeStream should default to UTF-8 with no charset`, async (_t) => { + const stream = decodeWeb.charsetDecodeStream(); + const output = await webStreamToString(stream, [Buffer.from("Test", "utf8")]); + strictEqual(output, "Test"); +}); + +test(`${variant}: web charsetDecodeStream should treat null charset as UTF-8`, async (_t) => { + const stream = decodeWeb.charsetDecodeStream({ charset: null }); + const output = await webStreamToString(stream, [ + Buffer.from("Hello", "utf8"), + ]); + strictEqual(output, "Hello"); +}); + +test(`${variant}: web charsetEncodeStream should default to UTF-8 with no charset`, async (_t) => { + const stream = encodeWeb.charsetEncodeStream(); + const output = await webStreamToBytes(stream, ["Test"]); + deepStrictEqual(output, Buffer.from("Test", "utf8")); +}); + +test(`${variant}: web charsetEncodeStream should treat null charset as UTF-8`, async (_t) => { + const stream = encodeWeb.charsetEncodeStream({ charset: null }); + const output = await webStreamToBytes(stream, ["Hello"]); + deepStrictEqual(output, Buffer.from("Hello", "utf8")); +}); + +// *** web-decode-allowlist-too-strict: ISO-8859-1 / ISO-8859-9 are accepted +// by TextDecoder and emitted by the detector, so decode must accept them *** // +test(`${variant}: web charsetDecodeStream should accept ISO-8859-1`, async (_t) => { + // 0xE9 is "é" in ISO-8859-1 (latin1). + const stream = decodeWeb.charsetDecodeStream({ charset: "ISO-8859-1" }); + const output = await webStreamToString(stream, [Buffer.from([0xe9])]); + strictEqual(output, "é"); +}); + +test(`${variant}: web charsetDecodeStream should accept ISO-8859-9`, async (_t) => { + const stream = decodeWeb.charsetDecodeStream({ charset: "ISO-8859-9" }); + const output = await webStreamToString(stream, [ + Buffer.from("abc", "latin1"), + ]); + strictEqual(output, "abc"); +}); + +test(`${variant}: web charsetDecodeStream should still reject genuinely unknown encodings`, async (_t) => { + try { + decodeWeb.charsetDecodeStream({ charset: "INVALID-CHARSET-999" }); + throw new Error("Expected error"); + } catch (e) { + ok( + e.message.includes("Unsupported web encoding"), + `unexpected error: ${e.message}`, + ); + } +}); + +// Run fn with the node-only `Buffer` global removed so any reference to it +// throws ReferenceError, simulating a browser runtime. createReadableStream / +// pipeline themselves do not depend on the Buffer global, so any +// "Buffer is not defined" comes from detect.js itself. +const withoutBuffer = async (fn) => { + const saved = globalThis.Buffer; + // Remove the global entirely (not just set undefined) so any reference to + // the bare `Buffer` identifier throws ReferenceError, matching a browser. + Reflect.deleteProperty(globalThis, "Buffer"); + try { + return await fn(); + } finally { + globalThis.Buffer = saved; + } +}; + +const driveDetect = async (inputs) => { + const streams = [ + createReadableStream(inputs), + detectSource.charsetDetectStream(), + ]; + await pipeline(streams); + return streams[1].result(); +}; + +// *** web-detect-buffer-crash: detect must not reference the node-only Buffer +// global in the web build. Exercise the source with BOTH a Uint8Array chunk +// and a string chunk (both pass through the byte-collection path that +// previously called Buffer.from / Buffer.concat) while Buffer is absent. *** // +test(`${variant}: web charsetDetectStream should detect ASCII without referencing Buffer`, async (_t) => { + const { key, value } = await withoutBuffer(() => + driveDetect([new TextEncoder().encode("Hello World")]), + ); + strictEqual(key, "charset"); + strictEqual(value.charset, "UTF-8"); + ok(value.confidence > 0, `confidence ${value.confidence} should be > 0`); +}); + +test(`${variant}: web charsetDetectStream should accept string chunks without Buffer`, async (_t) => { + const { value } = await withoutBuffer(() => driveDetect(["Test content"])); + strictEqual(value.charset, "UTF-8"); +}); + +test(`${variant}: web charsetDetectStream should signal unknown for empty input`, async (_t) => { + const { value } = await withoutBuffer(() => driveDetect([])); + strictEqual(value.charset, undefined); + strictEqual(value.confidence, 0); +}); + +// *** getSupportedEncoding via detect.js direct import: the function must be +// exported from the shared source so both node and web builds expose it. *** // +test(`${variant}: detect.js getSupportedEncoding should convert ISO-8859-8-I to ISO-8859-8`, (_t) => { + strictEqual(detectSource.getSupportedEncoding("ISO-8859-8-I"), "ISO-8859-8"); +}); + +test(`${variant}: detect.js getSupportedEncoding should pass through other charsets unchanged`, (_t) => { + strictEqual(detectSource.getSupportedEncoding("UTF-8"), "UTF-8"); +}); + +// *** charset key string mutations: each charset name in the allowlist must be +// correct so that chardet results are stored under the right key and the +// top-confidence charset is reported accurately. Mutating any name to "" means +// the chardet result is lost and a wrong (zero-confidence) charset wins. *** // + +test(`${variant}: charsetDetectStream should detect UTF-16BE`, async (_t) => { + // UTF-16BE BOM + "Hello World" + const bytes = Buffer.from([ + 0xfe, 0xff, 0x00, 0x48, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x6f, + 0x00, 0x20, 0x00, 0x57, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x6c, 0x00, 0x64, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-16BE"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect UTF-16LE`, async (_t) => { + // UTF-16LE BOM + "Hello World" + const bytes = Buffer.from([ + 0xff, 0xfe, 0x48, 0x00, 0x65, 0x00, 0x6c, 0x00, 0x6c, 0x00, 0x6f, 0x00, + 0x20, 0x00, 0x57, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x6c, 0x00, 0x64, 0x00, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-16LE"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect UTF-32BE`, async (_t) => { + // UTF-32BE encoding of "Hello World" + const bytes = Buffer.from([ + 0, 0, 0, 72, 0, 0, 0, 101, 0, 0, 0, 108, 0, 0, 0, 108, 0, 0, 0, 111, 0, 0, + 0, 32, 0, 0, 0, 87, 0, 0, 0, 111, 0, 0, 0, 114, 0, 0, 0, 108, 0, 0, 0, 100, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-32BE"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect UTF-32LE`, async (_t) => { + // UTF-32LE encoding of "Hello World" + const bytes = Buffer.from([ + 72, 0, 0, 0, 101, 0, 0, 0, 108, 0, 0, 0, 108, 0, 0, 0, 111, 0, 0, 0, 32, 0, + 0, 0, 87, 0, 0, 0, 111, 0, 0, 0, 114, 0, 0, 0, 108, 0, 0, 0, 100, 0, 0, 0, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-32LE"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect Shift_JIS`, async (_t) => { + // Shift_JIS: "\u3053\u308C\u306F\u30C6\u30B9\u30C8\u3067\u3059\u3002\u65E5\u672C\u8A9E" + const bytes = Buffer.from([ + 130, 177, 130, 234, 130, 205, 131, 101, 131, 88, 131, 103, 130, 197, 130, + 183, 129, 66, 147, 250, 150, 123, 140, 234, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "Shift_JIS"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect EUC-JP`, async (_t) => { + // EUC-JP: "\u3053\u308C\u306F\u30C6\u30B9\u30C8\u3067\u3059\u3002\u65E5\u672C\u8A9E" + const bytes = Buffer.from([ + 164, 179, 164, 236, 164, 207, 165, 198, 165, 185, 165, 200, 164, 199, 164, + 185, 161, 163, 198, 252, 203, 220, 184, 236, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "EUC-JP"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect EUC-KR`, async (_t) => { + // EUC-KR: "\uC548\uB155\uD558\uC138\uC694 \uD55C\uAD6D\uC5B4 \uD14D\uC2A4\uD2B8" + const bytes = Buffer.from([ + 190, 200, 179, 231, 199, 207, 188, 188, 191, 228, 32, 199, 209, 177, 185, + 190, 238, 32, 197, 216, 189, 186, 198, 174, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "EUC-KR"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect Big5`, async (_t) => { + // Big5: "\u9019\u662F\u6E2C\u8A66\u3002\u7E41\u9AD4\u4E2D\u6587\u6587\u672C" + const bytes = Buffer.from([ + 179, 111, 172, 79, 180, 250, 184, 213, 161, 67, 193, 99, 197, 233, 164, 164, + 164, 229, 164, 229, 165, 187, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "Big5"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect GB18030`, async (_t) => { + // GB18030: "\u8FD9\u662F\u6D4B\u8BD5\u3002\u7B80\u4F53\u4E2D\u6587\u6587\u672C" + const bytes = Buffer.from([ + 213, 226, 202, 199, 178, 226, 202, 212, 161, 163, 188, 242, 204, 229, 214, + 208, 206, 196, 206, 196, 177, 190, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "GB18030"); + strictEqual(value.confidence, 100); +}); + +test(`${variant}: charsetDetectStream should detect ISO-2022-JP`, async (_t) => { + // ISO-2022-JP escape-sequence encoded Japanese + const bytes = Buffer.from([ + 0x1b, 0x24, 0x42, 0x46, 0x7c, 0x38, 0x6b, 0x4c, 0x68, 0x1b, 0x28, 0x42, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-2022-JP"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-2022-KR`, async (_t) => { + // ISO-2022-KR escape-sequence encoded Korean + const bytes = Buffer.from([ + 0x1b, 0x24, 0x29, 0x43, 0x0e, 0x4a, 0x49, 0x4e, 0x59, 0x0f, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-2022-KR"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-2022-CN`, async (_t) => { + // ISO-2022-CN escape-sequence encoded Chinese + const bytes = Buffer.from([ + 0x1b, 0x24, 0x29, 0x41, 0x0e, 0x32, 0x36, 0x33, 0x37, 0x0f, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-2022-CN"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-1`, async (_t) => { + // ISO-8859-1: "La s\u00E9r\u00E9nade de pain fran\u00E7ais \u00E9tait tr\u00E8s \u00E9l\u00E9gante" + const bytes = Buffer.from([ + 0x4c, 0x61, 0x20, 0x73, 0xe9, 0x72, 0xe9, 0x6e, 0x61, 0x64, 0x65, 0x20, + 0x64, 0x65, 0x20, 0x70, 0x61, 0x69, 0x6e, 0x20, 0x66, 0x72, 0x61, 0xee, + 0xe7, 0x61, 0x69, 0x73, 0x20, 0xe9, 0x74, 0xe0, 0x69, 0x74, 0x20, 0x74, + 0x72, 0xe8, 0x73, 0x20, 0xe9, 0x6c, 0xe9, 0x67, 0x61, 0x6e, 0x74, 0x65, + 0x20, 0x63, 0x61, 0x72, 0x20, 0x65, 0x6c, 0x6c, 0x65, 0x20, 0x65, 0x73, + 0x74, 0x20, 0x74, 0x72, 0xe8, 0x73, 0x20, 0x62, 0x6f, 0x6e, 0x6e, 0x65, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-1"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-2`, async (_t) => { + // ISO-8859-2: Polish "Polskie znaki: szcz\u0119\u015Bcie, \u017Ar\u00F3d\u0142o, ni\u017C, b\u0142\u0105d" + const bytes = Buffer.from([ + 80, 111, 108, 115, 107, 105, 101, 32, 122, 110, 97, 107, 105, 58, 32, 115, + 122, 99, 122, 234, 182, 99, 105, 101, 44, 32, 188, 114, 243, 100, 179, 111, + 44, 32, 110, 105, 191, 44, 32, 98, 179, 177, 100, 44, 32, 243, 119, 44, 32, + 179, 177, 107, 97, 44, 32, 112, 114, 122, 121, 115, 122, 179, 111, 182, 230, + 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-2"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-5`, async (_t) => { + // ISO-8859-5: Cyrillic "\u042D\u0442\u043E \u0442\u0435\u0441\u0442\u043E\u0432\u044B\u0439 \u0442\u0435\u043A\u0441\u0442 \u043D\u0430 \u0440\u0443\u0441\u0441\u043A\u043E\u043C" + const bytes = Buffer.from([ + 205, 226, 222, 32, 226, 213, 225, 226, 222, 210, 235, 217, 32, 226, 213, + 218, 225, 226, 32, 221, 208, 32, 224, 227, 225, 225, 218, 222, 220, 32, 239, + 215, 235, 218, 213, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-5"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-6`, async (_t) => { + // ISO-8859-6: Arabic long text + const bytes = Buffer.from([ + 231, 208, 199, 32, 230, 213, 32, 217, 209, 200, 234, 46, 32, 199, 228, 230, + 213, 32, 199, 228, 217, 209, 200, 234, 32, 228, 228, 199, 206, 202, 200, + 199, 209, 46, 32, 199, 228, 199, 206, 202, 200, 199, 209, 32, 229, 231, 229, + 32, 204, 207, 199, 235, 46, 32, 230, 213, 32, 199, 206, 202, 200, 199, 209, + 32, 217, 209, 200, 234, 32, 229, 215, 232, 228, 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-6"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-7`, async (_t) => { + // ISO-8859-7: Greek "\u0391\u03C5\u03C4\u03CC \u03B5\u03AF\u03BD\u03B1\u03B9 \u03B5\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03CC \u03BA\u03B5\u03AF\u03BC\u03B5\u03BD\u03BF" + const bytes = Buffer.from([ + 193, 245, 244, 252, 32, 229, 223, 237, 225, 233, 32, 229, 235, 235, 231, + 237, 233, 234, 252, 32, 234, 229, 223, 236, 229, 237, 239, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-7"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-8`, async (_t) => { + // ISO-8859-8: Hebrew "\u05D6\u05D4\u05D5 \u05D8\u05E7\u05E1\u05D8 \u05D1\u05D3\u05D9\u05E7\u05D4 \u05D1\u05E2\u05D1\u05E8\u05D9\u05EA" + const bytes = Buffer.from([ + 230, 228, 229, 32, 232, 247, 241, 232, 32, 225, 227, 233, 247, 228, 32, 225, + 242, 225, 248, 233, 250, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-8"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect ISO-8859-9`, async (_t) => { + // ISO-8859-9: Turkish "Bu T\u00FCrk\u00E7e bir test metni G\u00FC\u015F\u0130\u00D6\u00C7\u015E\u0130" + const bytes = Buffer.from([ + 66, 117, 32, 84, 252, 114, 107, 231, 101, 32, 98, 105, 114, 32, 116, 101, + 115, 116, 32, 109, 101, 116, 110, 105, 32, 71, 252, 254, 221, 214, 199, 222, + 221, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "ISO-8859-9"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1251`, async (_t) => { + // windows-1251: Cyrillic "\u041F\u0440\u0438\u0432\u0435\u0442 \u043C\u0438\u0440 \u042D\u0442\u043E \u0442\u0435\u0441\u0442\u043E\u0432\u044B\u0439 \u0442\u0435\u043A\u0441\u0442 \u043D\u0430 \u0440\u0443\u0441\u0441\u043A\u043E\u043C" + const bytes = Buffer.from([ + 207, 240, 232, 226, 229, 242, 32, 236, 232, 240, 32, 221, 242, 238, 32, 242, + 229, 241, 242, 238, 226, 251, 233, 32, 242, 229, 234, 241, 242, 32, 237, + 224, 32, 240, 243, 241, 241, 234, 238, 236, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1251"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1252`, async (_t) => { + // windows-1252 specific code points in 0x80-0x9F range (EUR, ellipsis, quotes, dashes) + const bytes = Buffer.from([ + 0x80, 0x85, 0x93, 0x94, 0x96, 0x97, 0x20, 0x48, 0x65, 0x6c, 0x6c, 0x6f, + 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x20, 0x54, 0x65, 0x73, 0x74, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1252"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1250`, async (_t) => { + // windows-1250: Polish "Polskie znaki: szcz\u0119\u015Bcie, \u017Ar\u00F3d\u0142o, ni\u017C, b\u0142\u0105d" + const bytes = Buffer.from([ + 80, 111, 108, 115, 107, 105, 101, 32, 122, 110, 97, 107, 105, 58, 32, 115, + 122, 99, 122, 234, 156, 99, 105, 101, 44, 32, 159, 114, 243, 100, 179, 111, + 44, 32, 110, 105, 191, 44, 32, 98, 179, 185, 100, 44, 32, 243, 119, 44, 32, + 179, 185, 107, 97, 44, 32, 112, 114, 122, 121, 115, 122, 179, 111, 156, 230, + 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1250"); + ok(value.confidence > 0); +}); + +test(`${variant}: charsetDetectStream should detect windows-1256`, async (_t) => { + // windows-1256: Arabic long text + const bytes = Buffer.from([ + 229, 208, 199, 32, 228, 213, 32, 199, 206, 202, 200, 199, 209, 32, 200, 199, + 225, 225, 219, 201, 32, 199, 225, 218, 209, 200, 237, 201, 46, 32, 199, 225, + 223, 202, 199, 200, 201, 32, 199, 225, 218, 209, 200, 237, 201, 32, 204, + 227, 237, 225, 201, 32, 230, 227, 218, 222, 207, 201, 46, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1256"); + ok(value.confidence > 0); +}); + +// *** detect-string-chunk: a string chunk must be encoded to bytes via +// TextEncoder so detection works correctly. Mutating typeof chunk === "string" +// to false causes the string to be passed raw to Uint8Array.set(), which +// silently writes zeros and detection returns garbage/undefined charset. *** // +test(`${variant}: charsetDetectStream should correctly detect charset from a string chunk`, async (_t) => { + // String chunk passed directly (not as Buffer). The passThrough function must + // encode it to bytes; if the typeof guard is removed the result is zeros. + const streams = [ + createReadableStream(["Hello World Test Content"]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual( + value.charset, + "UTF-8", + `string chunk should detect UTF-8, got ${value.charset}`, + ); + ok( + value.confidence > 0, + `confidence ${value.confidence} should be > 0 for string input`, + ); +}); + +// *** decode-valid-charset-preserved: when iconv.encodingExists(charset) is +// true the fallback to UTF-8 must NOT fire. Mutating the guard to if(true) +// would always reset charset to UTF-8, breaking decodes of other charsets. *** // +test(`${variant}: charsetDecodeStream should preserve non-UTF-8 charset (not fall back)`, async (_t) => { + // 0xE9 0xE0 0xE8 are "\u00E9\u00E0\u00E8" in ISO-8859-1; as UTF-8 these bytes are invalid + // and would be replaced with the Unicode replacement character U+FFFD. + const iso1bytes = Buffer.from([0xe9, 0xe0, 0xe8]); + const streams = [ + createReadableStream([iso1bytes]), + charsetDecodeStream({ charset: "ISO-8859-1" }), + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + // Correct ISO-8859-1 decode yields "\u00E9\u00E0\u00E8" + strictEqual(output, "\u00E9\u00E0\u00E8"); + // If charset were wrongly replaced with UTF-8, output would contain U+FFFD + strictEqual(output.includes("\uFFFD"), false); +}); + +// *** decode-transform-empty-guard: conv.write() returns "" for partial +// multi-byte sequences; if the if(res?.length) guard is removed, empty strings +// are enqueued as extra chunks. A test counting chunks catches the mutant. *** // +test(`${variant}: charsetDecodeStream should not emit empty chunks for partial multi-byte sequences`, async (_t) => { + // U+1F600 emoji is 4 bytes in UTF-8: F0 9F 98 80. + // Split into 4 x 1-byte chunks so the first three write() calls return "". + // With guard: only 1 non-empty chunk ("\uD83D\uDE00") is enqueued. + // Without guard (if true): 4 chunks (3 x "" + "\uD83D\uDE00") are enqueued. + const emojiBytes = Buffer.from("\uD83D\uDE00", "utf8"); + const byteChunks = [ + emojiBytes.subarray(0, 1), + emojiBytes.subarray(1, 2), + emojiBytes.subarray(2, 3), + emojiBytes.subarray(3, 4), + ]; + const streams = [ + createReadableStream(byteChunks), + charsetDecodeStream({ charset: "UTF-8" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // Exactly one chunk: the complete emoji string + strictEqual(output.length, 1); + strictEqual(output[0], "\uD83D\uDE00"); +}); + +// *** encode-transform-empty-guard: conv.write() returns a 0-length Buffer for +// empty string chunks; if the guard is removed, empty Buffers are enqueued. +// A test checking the exact chunk count catches this mutant. *** // +test(`${variant}: charsetEncodeStream should not emit empty chunks for empty string inputs`, async (_t) => { + // Mix of empty strings and real content. Each empty string encodes to a + // 0-length Buffer. With guard: only the 2 non-empty inputs produce chunks. + // Without guard (if true): 5 chunks (3 x empty Buffer + 2 real). + const streams = [ + createReadableStream(["", "Hello", "", "World", ""]), + charsetEncodeStream({ charset: "UTF-8" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output.length, 2); + deepStrictEqual(output[0], Buffer.from("Hello")); + deepStrictEqual(output[1], Buffer.from("World")); +}); + +// *** detect-sample-cap: the passThrough must stop accumulating after +// MAX_DETECTION_SAMPLE (65536) bytes so memory is bounded and detection uses +// only the representative prefix. Mutating the guard to if(false) removes the +// cap entirely. This test confirms the stream completes without error and the +// sample cap code path is exercised. *** // +test(`${variant}: charsetDetectStream should handle input at and beyond MAX_DETECTION_SAMPLE`, async (_t) => { + // Send exactly MAX_DETECTION_SAMPLE + 1 bytes of ASCII content. + // Real code: returns early after 65536 bytes; extra byte is not sampled. + const MAX = 64 * 1024; // 65536 + const chunk1 = Buffer.alloc(MAX, 65); // 65536 x "A" + const chunk2 = Buffer.from([65]); // one more "A" + const streams = [ + createReadableStream([chunk1, chunk2]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "UTF-8"); + ok(value.confidence > 0); +}); + +// *** slice-overflow: when a single chunk is larger than MAX_DETECTION_SAMPLE, +// the real code must truncate to exactly MAX bytes. Mutants that skip the +// truncation (if(false) conditional, MAX+sampleLength arithmetic, <=remaining +// comparison) all append the overflow bytes to the sample, which shifts +// chardet's answer from UTF-8 (ASCII prefix) to KOI8-R (Cyrillic overflow). *** // +test(`${variant}: charsetDetectStream should truncate a single oversized chunk to MAX_DETECTION_SAMPLE`, async (_t) => { + // Build a single chunk: first MAX bytes are pure ASCII (→ UTF-8 when capped), + // followed by 32 KB of KOI8-R Cyrillic bytes (→ KOI8-R when overflow leaks). + const MAX = 64 * 1024; + const koi8rPattern = Buffer.from([ + 0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcb, 0xc1, 0xcb, 0x20, + 0xc4, 0xc5, 0xcc, 0xc1, 0x3f, 0x20, 0xfa, 0xd4, 0xcf, 0x20, 0xd4, 0xc5, + 0xd3, 0xd4, 0xcf, 0xd7, 0xd9, 0xca, 0x20, 0xd4, 0xc5, 0xcb, 0xd3, 0xd4, + 0x20, 0xce, 0xc1, 0x20, 0xd2, 0xd5, 0xd3, 0xd3, 0xcb, 0xcf, 0xcd, 0x20, + 0xd1, 0xda, 0xd9, 0xcb, 0xc5, 0x2e, + ]); + const overflow = Buffer.alloc(32 * 1024); + for (let i = 0; i < overflow.length; i++) + overflow[i] = koi8rPattern[i % koi8rPattern.length]; + + const singleChunk = Buffer.concat([Buffer.alloc(MAX, 0x41), overflow]); + const streams = [createReadableStream([singleChunk]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + + // Real code caps at MAX bytes (pure ASCII → UTF-8 @ confidence 100). + // Any overflow mutant includes KOI8-R bytes and detects KOI8-R instead. + strictEqual( + value.charset, + "UTF-8", + `oversized chunk: expected UTF-8 but got ${value.charset}@${value.confidence}`, + ); + strictEqual(value.confidence, 100); +}); + +// *** charsets-allowlist-windows-1254: chardet can report "windows-1254" +// (Turkish encoding with c1 bytes). The charsetKeys array must contain the +// exact string "windows-1254" so the match is stored and wins. Mutating the +// key to "" means chardet's result is discarded and windows-1250 wins instead. *** // +test(`${variant}: charsetDetectStream should detect windows-1254`, async (_t) => { + // windows-1254 Turkish text with c1-range bytes (0x80-0x9F) that force the + // windows-1254 detector over ISO-8859-9. Bytes produced by: + // iconv.encode('Türkçe metin ...', 'windows-1254') plus c1 bytes. + const bytes = Buffer.from([ + 128, 156, 157, 158, 145, 146, 147, 148, 84, 252, 114, 107, 231, 101, 32, + 109, 101, 116, 105, 110, 32, 254, 105, 105, 114, 32, 107, 97, 108, 101, 109, + 32, 246, 240, 114, 101, 116, 109, 101, 110, 32, 103, 252, 122, 101, 108, 32, + 231, 111, 99, 117, 107, 32, 100, 252, 110, 121, 97, 32, 97, 105, 108, 101, + 128, 156, 157, 158, 145, 146, 147, 148, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual(value.charset, "windows-1254"); + ok(value.confidence > 0); +}); + +// *** charsets-allowlist-filter: the "name in charsets" guard must filter +// chardet results to only the charsets listed in charsetKeys. Mutating the +// guard to "if(true)" lets unknown charset names (e.g. "windows-1255") bypass +// the filter and potentially win at high confidence, displacing the real winner +// from the charsetKeys allowlist. *** // +test(`${variant}: charsetDetectStream should filter chardet results to charsetKeys allowlist`, async (_t) => { + // windows-1255 Hebrew bytes with c1 bytes. chardet returns "windows-1255" @ + // confidence 71 as the top match. "windows-1255" is NOT in charsetKeys, so + // the real code discards it; GB18030 wins at 10 from the in-allowlist matches. + // With if(true) mutant, "windows-1255" leaks through and wins at 71. + const bytes = Buffer.from([ + 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 249, 236, 229, 237, + 32, 242, 229, 236, 237, 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, + 140, 249, 236, 229, 237, 32, 242, 229, 236, 237, + ]); + const streams = [createReadableStream([bytes]), charsetDetectStream()]; + await pipeline(streams); + const { value } = streams[1].result(); + // Real code: windows-1255 discarded → GB18030 wins at confidence 10. + // Mutant (if true): windows-1255 leaks → wins at confidence 71. + strictEqual( + value.charset, + "GB18030", + `expected GB18030 (in-allowlist winner) but got ${value.charset}@${value.confidence}`, + ); +}); + +// *** remaining-arithmetic: "remaining = MAX - sampleLength" bounds how many +// bytes of each subsequent chunk are stored. Mutating to MAX + sampleLength +// makes remaining huge so the second chunk is never truncated, leaking +// post-cap bytes into the sample and shifting detection from UTF-8 to KOI8-R. +// Only observable with TWO chunks (when sampleLength === 0 both expressions +// give MAX, so the first chunk is handled identically). *** // +test(`${variant}: charsetDetectStream should correctly cap the sample across two chunks`, async (_t) => { + const MAX = 64 * 1024; + const koi8rPattern = Buffer.from([ + 0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x2c, 0x20, 0xcb, 0xc1, 0xcb, 0x20, + 0xc4, 0xc5, 0xcc, 0xc1, 0x3f, 0x20, 0xfa, 0xd4, 0xcf, 0x20, 0xd4, 0xc5, + 0xd3, 0xd4, 0xcf, 0xd7, 0xd9, 0xca, 0x20, 0xd4, 0xc5, 0xcb, 0xd3, 0xd4, + 0x20, 0xce, 0xc1, 0x20, 0xd2, 0xd5, 0xd3, 0xd3, 0xcb, 0xcf, 0xcd, 0x20, + 0xd1, 0xda, 0xd9, 0xcb, 0xc5, 0x2e, + ]); + const koi8r = Buffer.alloc(MAX / 2); + for (let i = 0; i < koi8r.length; i++) + koi8r[i] = koi8rPattern[i % koi8rPattern.length]; + + // Chunk 1 fills MAX/2 bytes (sampleLength = MAX/2 after it). + // Chunk 2 has MAX/2 bytes ASCII followed by MAX/2 bytes KOI8-R. + // Real: remaining = MAX - MAX/2 = MAX/2, so only first MAX/2 of chunk2 kept. + // Mutant: remaining = MAX + MAX/2 (huge), full chunk2 kept, KOI8-R leaks in. + const chunk1 = Buffer.alloc(MAX / 2, 0x41); + const chunk2 = Buffer.concat([Buffer.alloc(MAX / 2, 0x41), koi8r]); + const streams = [ + createReadableStream([chunk1, chunk2]), + charsetDetectStream(), + ]; + await pipeline(streams); + const { value } = streams[1].result(); + strictEqual( + value.charset, + "UTF-8", + `two-chunk cap: expected UTF-8 but got ${value.charset}@${value.confidence}`, + ); + strictEqual(value.confidence, 100); +}); diff --git a/packages/charset/package.json b/packages/charset/package.json index eb86556..943428a 100644 --- a/packages/charset/package.json +++ b/packages/charset/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/charset", - "version": "0.5.0", + "version": "0.6.1", "description": "Character encoding detection, decoding, and conversion streams", "type": "module", "engines": { @@ -87,7 +87,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -108,8 +109,8 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", - "chardet": "2.1.1", - "iconv-lite": "0.7.2" + "@datastream/core": "0.6.1", + "chardet": "2.2.0", + "iconv-lite": "0.7.3" } } diff --git a/packages/compress/README.md b/packages/compress/README.md index 69d3f05..09433ff 100644 --- a/packages/compress/README.md +++ b/packages/compress/README.md @@ -1,6 +1,6 @@Compression streams for gzip, brotli, zstd, and deflate.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/compress/brotli.node.js b/packages/compress/brotli.node.js index 1f75985..9a3b3fa 100644 --- a/packages/compress/brotli.node.js +++ b/packages/compress/brotli.node.js @@ -6,43 +6,61 @@ import { createBrotliDecompress, } from "node:zlib"; +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + // quality: 0 - 11 -export const brotliCompressStream = ({ quality } = {}, streamOptions = {}) => { - return createBrotliCompress({ +export const brotliCompressStream = (options = {}, streamOptions = {}) => { + const { quality, maxOutputSize } = options; + const stream = createBrotliCompress({ ...streamOptions, params: { [constants.BROTLI_PARAM_QUALITY]: quality ?? constants.BROTLI_DEFAULT_QUALITY, }, }); + if (maxOutputSize !== null && maxOutputSize !== undefined) { + guardOutput(stream, maxOutputSize, "Compression"); + } + return stream; }; export const brotliDecompressStream = (options = {}, streamOptions = {}) => { - const { maxOutputSize, ...params } = options; - const zlibOptions = Object.keys(params).length - ? { ...streamOptions, params } - : streamOptions; + const { maxOutputSize, params } = options; + const zlibOptions = params ? { ...streamOptions, params } : streamOptions; const stream = createBrotliDecompress(zlibOptions); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/compress/brotli.web.js b/packages/compress/brotli.web.js index 16f17c5..54e917a 100644 --- a/packages/compress/brotli.web.js +++ b/packages/compress/brotli.web.js @@ -8,54 +8,98 @@ import { createTransformStream } from "@datastream/core"; import brotliPromise from "brotli-wasm"; // Import the default export -const { CompressStream, DecompressStream, BrotliStreamResult } = +const { CompressStream, DecompressStream, BrotliStreamResultCode } = await brotliPromise; // Import is async in browsers due to wasm requirements! +// Fixed-size output buffer; the streaming loop drains NeedsMoreOutput so any +// chunk/output size is handled correctly. +const OUTPUT_SIZE = 16_384; // 16KB + +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +const toBytes = (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk; + // https://github.com/httptoolkit/brotli-wasm/issues/14 export const brotliCompressStream = (options = {}, streamOptions = {}) => { - const { quality } = options; + const { quality, maxOutputSize } = options; const engine = new CompressStream(quality ?? 11); + let outputSize = 0; + const guard = (buf, enqueue) => { + if (maxOutputSize != null) { + outputSize += buf.byteLength; + if (outputSize > maxOutputSize) { + throw new Error( + `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ); + } + } + enqueue(buf); + }; const transform = (chunk, enqueue) => { - enqueue(engine.compress(chunk)); + const input = toBytes(chunk); + let inputOffset = 0; + let code; + do { + const result = engine.compress(input.slice(inputOffset), OUTPUT_SIZE); + guard(result.buf, enqueue); + inputOffset += result.input_offset; + code = result.code; + } while (code === BrotliStreamResultCode.NeedsMoreOutput); }; const flush = (enqueue) => { - if (engine.result() === BrotliStreamResult.NeedsMoreInput) { - enqueue(engine.compress(undefined, 100)); - } + let code; + do { + const result = engine.compress(undefined, OUTPUT_SIZE); + guard(result.buf, enqueue); + code = result.code; + } while (code === BrotliStreamResultCode.NeedsMoreOutput); }; return createTransformStream(transform, flush, streamOptions); }; export const brotliDecompressStream = (options = {}, streamOptions = {}) => { const { maxOutputSize } = options; + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); const engine = new DecompressStream(); let outputSize = 0; const transform = (chunk, enqueue) => { - const result = engine.decompress(chunk); - if (maxOutputSize != null) { - outputSize += result.byteLength; - if (outputSize > maxOutputSize) { - throw new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ); - } - } - enqueue(result); - }; - const flush = (enqueue) => { - if (engine.result() === BrotliStreamResult.NeedsMoreInput) { - const result = engine.decompress(undefined, 100); - if (maxOutputSize != null) { - outputSize += result.byteLength; - if (outputSize > maxOutputSize) { + const input = toBytes(chunk); + let inputOffset = 0; + let code; + do { + const result = engine.decompress(input.slice(inputOffset), OUTPUT_SIZE); + if (limit !== undefined) { + outputSize += result.buf.byteLength; + if (outputSize > limit) { throw new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + `Decompression output exceeds maxOutputSize (${limit} bytes)`, ); } } - enqueue(result); + enqueue(result.buf); + inputOffset += result.input_offset; + code = result.code; + } while (code === BrotliStreamResultCode.NeedsMoreOutput); + // A complete brotli stream (ResultSuccess) followed by leftover bytes in + // the same chunk means trailing/garbage data; strict decoders reject it + // rather than silently dropping it. + if ( + code === BrotliStreamResultCode.ResultSuccess && + inputOffset < input.byteLength + ) { + throw new Error( + "Decompression has trailing bytes after end of brotli stream", + ); } }; - return createTransformStream(transform, flush, streamOptions); + return createTransformStream(transform, undefined, streamOptions); }; export default { diff --git a/packages/compress/deflate.node.js b/packages/compress/deflate.node.js index ff80036..9112b4f 100644 --- a/packages/compress/deflate.node.js +++ b/packages/compress/deflate.node.js @@ -2,58 +2,54 @@ // SPDX-License-Identifier: MIT import { createDeflate, createInflate } from "node:zlib"; +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + // quality -1 - 9 -export const deflateCompressStream = (options = {}, _streamOptions = {}) => { - const { quality, maxOutputSize, ...rest } = options; - const stream = createDeflate({ ...rest, level: rest.level ?? quality }); +export const deflateCompressStream = (options = {}, streamOptions = {}) => { + const { quality, maxOutputSize, level } = options; + const stream = createDeflate({ ...streamOptions, level: level ?? quality }); if (maxOutputSize !== null && maxOutputSize !== undefined) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + guardOutput(stream, maxOutputSize, "Compression"); } return stream; }; export const deflateDecompressStream = (options = {}, streamOptions = {}) => { const { maxOutputSize } = options; const stream = createInflate(streamOptions); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/compress/deflate.web.js b/packages/compress/deflate.web.js index fdc9a80..c5d0eff 100644 --- a/packages/compress/deflate.web.js +++ b/packages/compress/deflate.web.js @@ -5,60 +5,119 @@ // - https://caniuse.com/?search=CompressionStream // - not supported on firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1586639 // - not supported in safari +import { makeOptions } from "@datastream/core"; -export const deflateCompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const compressor = new CompressionStream("deflate"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +// The WHATWG CompressionStream/DecompressionStream require each chunk to be a +// BufferSource; strings throw a TypeError in spec-compliant browsers. Node's +// implementation is lenient, so convert here to match Node + web brotli. +const toBytes = (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk; + +// Input stage: convert string chunks to bytes (BufferSource) and honor an +// AbortSignal. The native CompressionStream/DecompressionStream accept neither a +// signal nor string chunks, so this stage provides both (parity with the Node +// build and web brotli). highWaterMark/chunkSize are threaded via makeOptions. +const inputStage = (streamOptions) => { + const { signal } = streamOptions ?? {}; + const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + let onAbort; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream( + { + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, transform(chunk, controller) { - outputSize += chunk.byteLength; - if (outputSize > maxOutputSize) { - controller.error( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return; + controller.enqueue(toBytes(chunk)); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; } - controller.enqueue(chunk); }, - }; - const limiter = new TransformStream(transformer); - return { - readable: compressor.readable.pipeThrough(limiter), - writable: compressor.writable, - }; - } - return compressor; + }, + writableStrategy, + readableStrategy, + ); }; -export const deflateDecompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const decompressor = new DecompressionStream("deflate"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { - transform(chunk, controller) { + +// Output stage: enforce maxOutputSize and keep honoring the AbortSignal for the +// whole stream lifetime (a mid-flight abort errors this terminal readable). +const outputStage = (maxOutputSize, label, streamOptions) => { + const { signal } = streamOptions ?? {}; + let onAbort; + let outputSize = 0; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream({ + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, + transform(chunk, controller) { + if (maxOutputSize !== null && maxOutputSize !== undefined) { outputSize += chunk.byteLength; if (outputSize > maxOutputSize) { controller.error( new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, ), ); return; } - controller.enqueue(chunk); - }, - }; - const limiter = new TransformStream(transformer); - return { - readable: decompressor.readable.pipeThrough(limiter), - writable: decompressor.writable, - }; - } - return decompressor; + } + controller.enqueue(chunk); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }, + }); +}; + +const wrap = (compressor, maxOutputSize, streamOptions, label) => { + const input = inputStage(streamOptions); + const output = outputStage(maxOutputSize, label, streamOptions); + const readable = input.readable.pipeThrough(compressor).pipeThrough(output); + return { readable, writable: input.writable }; +}; + +export const deflateCompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const compressor = new CompressionStream("deflate"); + return wrap(compressor, maxOutputSize, streamOptions, "Compression"); +}; +export const deflateDecompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const decompressor = new DecompressionStream("deflate"); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + return wrap(decompressor, limit, streamOptions, "Decompression"); }; export default { diff --git a/packages/compress/gzip.node.js b/packages/compress/gzip.node.js index c0a9255..68a8892 100644 --- a/packages/compress/gzip.node.js +++ b/packages/compress/gzip.node.js @@ -2,58 +2,54 @@ // SPDX-License-Identifier: MIT import { createGunzip, createGzip } from "node:zlib"; +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const guardOutput = (stream, maxOutputSize, label) => { + let outputSize = 0; + const originalPush = stream.push.bind(stream); + stream.push = (chunk, encoding) => { + if (chunk !== null) { + outputSize += chunk.byteLength ?? Buffer.byteLength(chunk); + if (outputSize > maxOutputSize) { + stream.push = originalPush; + stream.destroy( + new Error( + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, + ), + ); + return false; + } + } + return originalPush(chunk, encoding); + }; + const restore = () => { + stream.push = originalPush; + }; + stream.on("close", restore); + stream.on("error", restore); +}; + // quality -1 - 9 export const gzipCompressStream = (options = {}, streamOptions = {}) => { const { quality, maxOutputSize } = options; const stream = createGzip({ ...streamOptions, level: quality }); if (maxOutputSize !== null && maxOutputSize !== undefined) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + guardOutput(stream, maxOutputSize, "Compression"); } return stream; }; export const gzipDecompressStream = (options = {}, streamOptions = {}) => { const { maxOutputSize } = options; const stream = createGunzip(streamOptions); - if (maxOutputSize != null) { - let outputSize = 0; - const originalPush = stream.push.bind(stream); - stream.push = (chunk) => { - if (chunk !== null) { - outputSize += chunk.length; - if (outputSize > maxOutputSize) { - stream.push = originalPush; - stream.destroy( - new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return false; - } - } - return originalPush(chunk); - }; - stream.on("close", () => { - stream.push = originalPush; - }); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + if (limit !== undefined) { + guardOutput(stream, limit, "Decompression"); } return stream; }; diff --git a/packages/compress/gzip.web.js b/packages/compress/gzip.web.js index 7776ee1..465688a 100644 --- a/packages/compress/gzip.web.js +++ b/packages/compress/gzip.web.js @@ -5,60 +5,119 @@ // - https://caniuse.com/?search=CompressionStream // - not supported on firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1586639 // - not supported in safari +import { makeOptions } from "@datastream/core"; -export const gzipCompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const compressor = new CompressionStream("gzip"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { +// Default decompression output ceiling (256MiB) so that untrusted compressed +// input is bounded by default (zip-bomb protection). Pass `maxOutputSize: null` +// to opt out of the limit entirely. +const DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE = 256 * 1024 * 1024; + +const textEncoder = new TextEncoder(); +// The WHATWG CompressionStream/DecompressionStream require each chunk to be a +// BufferSource; strings throw a TypeError in spec-compliant browsers. Node's +// implementation is lenient, so convert here to match Node + web brotli. +const toBytes = (chunk) => + typeof chunk === "string" ? textEncoder.encode(chunk) : chunk; + +// Input stage: convert string chunks to bytes (BufferSource) and honor an +// AbortSignal. The native CompressionStream/DecompressionStream accept neither a +// signal nor string chunks, so this stage provides both (parity with the Node +// build and web brotli). highWaterMark/chunkSize are threaded via makeOptions. +const inputStage = (streamOptions) => { + const { signal } = streamOptions ?? {}; + const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + let onAbort; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream( + { + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, transform(chunk, controller) { - outputSize += chunk.byteLength; - if (outputSize > maxOutputSize) { - controller.error( - new Error( - `Compression output exceeds maxOutputSize (${maxOutputSize} bytes)`, - ), - ); - return; + controller.enqueue(toBytes(chunk)); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; } - controller.enqueue(chunk); }, - }; - const limiter = new TransformStream(transformer); - return { - readable: compressor.readable.pipeThrough(limiter), - writable: compressor.writable, - }; - } - return compressor; + }, + writableStrategy, + readableStrategy, + ); }; -export const gzipDecompressStream = (options = {}, _streamOptions = {}) => { - const { maxOutputSize } = options; - const decompressor = new DecompressionStream("gzip"); - if (maxOutputSize != null) { - let outputSize = 0; - const transformer = { - transform(chunk, controller) { + +// Output stage: enforce maxOutputSize and keep honoring the AbortSignal for the +// whole stream lifetime (a mid-flight abort errors this terminal readable). +const outputStage = (maxOutputSize, label, streamOptions) => { + const { signal } = streamOptions ?? {}; + let onAbort; + let outputSize = 0; + const abortError = () => + signal.reason ?? new DOMException("Aborted", "AbortError"); + return new TransformStream({ + start(controller) { + if (signal) { + if (signal.aborted) { + controller.error(abortError()); + return; + } + onAbort = () => controller.error(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + } + }, + transform(chunk, controller) { + if (maxOutputSize !== null && maxOutputSize !== undefined) { outputSize += chunk.byteLength; if (outputSize > maxOutputSize) { controller.error( new Error( - `Decompression output exceeds maxOutputSize (${maxOutputSize} bytes)`, + `${label} output exceeds maxOutputSize (${maxOutputSize} bytes)`, ), ); return; } - controller.enqueue(chunk); - }, - }; - const limiter = new TransformStream(transformer); - return { - readable: decompressor.readable.pipeThrough(limiter), - writable: decompressor.writable, - }; - } - return decompressor; + } + controller.enqueue(chunk); + }, + flush() { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }, + }); +}; + +const wrap = (compressor, maxOutputSize, streamOptions, label) => { + const input = inputStage(streamOptions); + const output = outputStage(maxOutputSize, label, streamOptions); + const readable = input.readable.pipeThrough(compressor).pipeThrough(output); + return { readable, writable: input.writable }; +}; + +export const gzipCompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const compressor = new CompressionStream("gzip"); + return wrap(compressor, maxOutputSize, streamOptions, "Compression"); +}; +export const gzipDecompressStream = (options = {}, streamOptions = {}) => { + const { maxOutputSize } = options; + const decompressor = new DecompressionStream("gzip"); + const limit = + maxOutputSize === null + ? undefined + : (maxOutputSize ?? DEFAULT_DECOMPRESS_MAX_OUTPUT_SIZE); + return wrap(decompressor, limit, streamOptions, "Decompression"); }; export default { diff --git a/packages/compress/index.d.ts b/packages/compress/index.d.ts index ebe4c82..d0aa8d2 100644 --- a/packages/compress/index.d.ts +++ b/packages/compress/index.d.ts @@ -13,10 +13,6 @@ export { gzipCompressStream, gzipDecompressStream, } from "@datastream/compress/gzip"; -export { - protobufDeserializeStream, - protobufSerializeStream, -} from "@datastream/compress/protobuf"; export { zstdCompressStream, zstdDecompressStream, diff --git a/packages/compress/index.js b/packages/compress/index.js index 9712e8b..c7b767e 100644 --- a/packages/compress/index.js +++ b/packages/compress/index.js @@ -3,5 +3,4 @@ export * from "@datastream/compress/brotli"; export * from "@datastream/compress/deflate"; export * from "@datastream/compress/gzip"; -export * from "@datastream/compress/protobuf"; export * from "@datastream/compress/zstd"; diff --git a/packages/compress/index.test.js b/packages/compress/index.test.js index 94fa665..4211218 100644 --- a/packages/compress/index.test.js +++ b/packages/compress/index.test.js @@ -1,9 +1,14 @@ import { ok, strictEqual } from "node:assert"; +import { realpathSync } from "node:fs"; +import { register } from "node:module"; import test from "node:test"; +import { pathToFileURL } from "node:url"; import { brotliCompressSync, + brotliDecompressSync, deflateSync, gzipSync, + constants as zlibConstants, zstdCompressSync, zstdDecompressSync, } from "node:zlib"; @@ -23,7 +28,6 @@ import { createReadableStream, pipejoin, pipeline, - streamToArray, streamToBuffer, streamToString, } from "@datastream/core"; @@ -158,6 +162,18 @@ if (variant === "node") { strictEqual(output, compressibleBody); }); + // A default decompression ceiling now applies; `maxOutputSize: null` opts + // out of any limit (unbounded), so normal payloads still round-trip. + test(`${variant}: gzipDecompressStream maxOutputSize:null disables the limit`, async (_t) => { + const input = gzipSync(compressibleBody); + const streams = [ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: null }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, compressibleBody); + }); + // *** maxOutputSize within limit (covers normal-path push) *** // test(`${variant}: gzipDecompressStream should pass through within maxOutputSize`, async (_t) => { const input = gzipSync(compressibleBody); @@ -189,6 +205,20 @@ if (variant === "node") { strictEqual(output, compressibleBody); }); + // Arbitrary keys on the first (options) argument must NOT be promoted into + // zlib Brotli `params` (the old behavior threw "chunkSize is not a valid + // Brotli parameter"). Only an explicit `params` field is forwarded; real + // stream options go on the second argument. + test(`${variant}: brotliDecompressStream should not promote first-arg keys to Brotli params`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + const streams = [ + createReadableStream(input), + brotliDecompressStream({ chunkSize: 1024 }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, compressibleBody); + }); + // *** zstd decompress maxOutputSize *** // test(`${variant}: zstdDecompressStream should enforce maxOutputSize`, async (_t) => { const input = zstdCompressSync(compressibleBody); @@ -214,6 +244,26 @@ if (variant === "node") { strictEqual(output, compressibleBody); }); + // Covers `maxOutputSize === null ? void 0 :` true-branch in zstdDecompressStream. + test(`${variant}: zstdDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = zstdCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + zstdDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + // guardOutput Buffer.byteLength(chunk) fallback for zstd (node-only): push a + // string directly into the guarded stream so .byteLength is undefined. + test(`${variant}: zstdDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = zstdDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); + }); + // *** compress maxOutputSize *** // test(`${variant}: gzipCompressStream should enforce maxOutputSize`, async (_t) => { const input = compressibleBody; @@ -262,48 +312,47 @@ if (variant === "node") { const output = await streamToString(pipejoin(streams)); strictEqual(output, deflateSync(compressibleBody).toString()); }); -} -// *** protobuf *** // -let protobuf; -try { - protobuf = (await import("protobufjs")).default; -} catch { - // protobufjs not installed -} + // deflateCompressStream reads quality/level/maxOutputSize from the first + // argument and forwards real stream options from the second argument + // (matching gzipCompressStream), rather than spreading the first arg. + test(`${variant}: deflateCompressStream should honor quality and second-arg stream options`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + deflateCompressStream({ quality: 9 }, { chunkSize: 1024 }), + deflateDecompressStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, compressibleBody); + }); -if (protobuf) { - const { protobufSerializeStream, protobufDeserializeStream } = await import( - "@datastream/compress/protobuf" - ); + test(`${variant}: brotliCompressStream should enforce maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 5 }), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); - const TestType = new protobuf.Type("TestMessage") - .add(new protobuf.Field("name", 1, "string")) - .add(new protobuf.Field("value", 2, "int32")); - new protobuf.Root().define("test").add(TestType); - - test(`${variant}: protobuf roundtrip serialize/deserialize`, async (_t) => { - const input = [ - { name: "a", value: 1 }, - { name: "b", value: 2 }, - ]; - const serialize = protobufSerializeStream({ Type: TestType }); - const deserialize = protobufDeserializeStream({ Type: TestType }); - const streams = [createReadableStream(input), serialize, deserialize]; - const output = await streamToArray(pipejoin(streams)); - strictEqual(output.length, 2); - strictEqual(output[0].name, "a"); - strictEqual(output[1].name, "b"); - }); - - test(`${variant}: protobufDeserializeStream should enforce maxOutputSize`, async (_t) => { - const input = [{ name: "long-name-here", value: 12345 }]; - const serialize = protobufSerializeStream({ Type: TestType }); - const deserialize = protobufDeserializeStream({ - Type: TestType, - maxOutputSize: 5, - }); - const streams = [createReadableStream(input), serialize, deserialize]; + test(`${variant}: brotliCompressStream should pass through within maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 1024 * 1024 }), + ]; + const output = await streamToBuffer(pipejoin(streams)); + strictEqual(brotliDecompressSync(output).toString(), compressibleBody); + }); + + test(`${variant}: zstdCompressStream should enforce maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: 5 }), + ]; try { await pipeline(streams); throw new Error("Should have thrown"); @@ -312,19 +361,661 @@ if (protobuf) { } }); - test(`${variant}: protobufDeserializeStream should pass through within maxOutputSize`, async (_t) => { - const input = [{ name: "a", value: 1 }]; - const serialize = protobufSerializeStream({ Type: TestType }); - const deserialize = protobufDeserializeStream({ - Type: TestType, - maxOutputSize: 1024, - }); - const streams = [createReadableStream(input), serialize, deserialize]; - const output = await streamToArray(pipejoin(streams)); - strictEqual(output[0].name, "a"); + test(`${variant}: zstdCompressStream should pass through within maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: 1024 * 1024 }), + ]; + const output = await streamToBuffer(pipejoin(streams)); + strictEqual(zstdDecompressSync(output).toString(), compressibleBody); }); } +// *** quality / level parameter routing *** // +if (variant === "node") { + // brotliCompressStream: quality=0 vs quality=11 produce different sizes; + // ensures params object is forwarded (ObjectLiteral survivor). + test(`${variant}: brotliCompressStream quality:0 differs from quality:11`, async (_t) => { + const out0 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ quality: 0 }), + ]), + ); + const out11 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ quality: 11 }), + ]), + ); + ok( + out0.byteLength !== out11.byteLength, + "different quality => different size", + ); + strictEqual(brotliDecompressSync(out0).toString(), compressibleBody); + strictEqual(brotliDecompressSync(out11).toString(), compressibleBody); + }); + + // default quality matches BROTLI_DEFAULT_QUALITY constant + // (covers quality ?? BROTLI_DEFAULT_QUALITY LogicalOperator survivor). + test(`${variant}: brotliCompressStream default quality equals explicit BROTLI_DEFAULT_QUALITY`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const outDef = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream(), + ]), + ); + const outExp = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ quality: zlibConst.BROTLI_DEFAULT_QUALITY }), + ]), + ); + strictEqual(outDef.toString("hex"), outExp.toString("hex")); + }); + + // gzipCompressStream: quality maps to zlib level; levels 1 and 9 differ. + test(`${variant}: gzipCompressStream quality:1 and quality:9 both round-trip`, async (_t) => { + const out1 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + gzipCompressStream({ quality: 1 }), + ]), + ); + const out9 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + gzipCompressStream({ quality: 9 }), + ]), + ); + ok(out1.byteLength !== out9.byteLength, "quality 1 vs 9 => different size"); + strictEqual( + await streamToString( + pipejoin([createReadableStream(out1), gzipDecompressStream()]), + ), + compressibleBody, + ); + strictEqual( + await streamToString( + pipejoin([createReadableStream(out9), gzipDecompressStream()]), + ), + compressibleBody, + ); + }); + + // deflateCompressStream: `level` takes precedence over `quality` + // (covers `level ?? quality` LogicalOperator survivor). + test(`${variant}: deflateCompressStream level overrides quality`, async (_t) => { + const compressed = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ level: 9, quality: 1 }), + ]), + ); + const output = await streamToString( + pipejoin([createReadableStream(compressed), deflateDecompressStream()]), + ); + strictEqual(output, compressibleBody); + }); + + // deflateCompressStream with quality:9 must match deflateSync at level:9. + // `level && quality` mutation: when level is undefined, `undefined && quality` + // = `undefined` → quality ignored → output would match default level, not 9. + // Also catches `createDeflate({})` mutation (drops level entirely). + test(`${variant}: deflateCompressStream quality:9 output matches deflateSync level:9`, async (_t) => { + const { deflateSync: defSync } = await import("node:zlib"); + const output = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ quality: 9 }), + ]), + ); + strictEqual( + output.toString("hex"), + defSync(compressibleBody, { level: 9 }).toString("hex"), + ); + }); + + // deflateCompressStream with level:1 must match deflateSync at level:1. + test(`${variant}: deflateCompressStream level:1 output matches deflateSync level:1`, async (_t) => { + const { deflateSync: defSync } = await import("node:zlib"); + const output = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ level: 1 }), + ]), + ); + strictEqual( + output.toString("hex"), + defSync(compressibleBody, { level: 1 }).toString("hex"), + ); + }); + + // zstdCompressStream: different quality levels both round-trip. + test(`${variant}: zstdCompressStream quality:1 and quality:19 both round-trip`, async (_t) => { + const out1 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ quality: 1 }), + ]), + ); + const out19 = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ quality: 19 }), + ]), + ); + strictEqual(zstdDecompressSync(out1).toString(), compressibleBody); + strictEqual(zstdDecompressSync(out19).toString(), compressibleBody); + }); + + // zstdCompressStream with explicit params (covers `params ?? { ... }` ObjectLiteral survivor). + test(`${variant}: zstdCompressStream with explicit params round-trips`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const out = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ + params: { [zlibConst.ZSTD_c_compressionLevel]: 3 }, + }), + ]), + ); + strictEqual(zstdDecompressSync(out).toString(), compressibleBody); + }); + + // zstdDecompressStream with explicit params + // (covers `params ? {..., params} : streamOptions` ConditionalExpression survivor). + test(`${variant}: zstdDecompressStream with explicit params round-trips`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const input = zstdCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + zstdDecompressStream({ + params: { [zlibConst.ZSTD_d_windowLogMax]: 27 }, + }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + // brotliDecompressStream with params option + // (covers `params ? {..., params} : streamOptions` ConditionalExpression survivor). + test(`${variant}: brotliDecompressStream with params option round-trips`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const input = brotliCompressSync(compressibleBody); + // BROTLI_PARAM_MODE is a valid decompress-time param (mode 0 = generic) + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ + params: { [zlibConst.BROTLI_PARAM_MODE]: 0 }, + }), + ]), + ); + strictEqual(output, compressibleBody); + }); +} + +// *** maxOutputSize exact boundary (> vs >=) *** // +// Payload whose decompressed size exactly equals maxOutputSize must PASS. +// `outputSize > maxOutputSize` allows equal; `>=` would reject it. +if (variant === "node") { + test(`${variant}: gzipDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = gzipSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + test(`${variant}: deflateDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = deflateSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + deflateDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + test(`${variant}: brotliDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + test(`${variant}: zstdDecompressStream exact-boundary maxOutputSize should NOT throw`, async (_t) => { + const input = zstdCompressSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + zstdDecompressStream({ maxOutputSize: exactSize }), + ]), + ); + strictEqual(output, compressibleBody); + }); + + // One byte under the limit MUST throw. + test(`${variant}: gzipDecompressStream one-byte-under maxOutputSize should throw`, async (_t) => { + const input = gzipSync(compressibleBody); + const exactSize = Buffer.byteLength(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: exactSize - 1 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize"), e.message); + } + }); +} + +// *** error message label strings (StringLiteral survivors) *** // +// Error message must contain "Compression"/"Decompression" (not ""). +if (variant === "node") { + test(`${variant}: gzipDecompressStream error message contains "Decompression"`, async (_t) => { + const input = gzipSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: gzipCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + gzipCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: deflateDecompressStream error message contains "Decompression"`, async (_t) => { + const input = deflateSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + deflateDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: deflateCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + deflateCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: brotliDecompressStream error message contains "Decompression"`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + brotliDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: brotliCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: zstdDecompressStream error message contains "Decompression"`, async (_t) => { + const input = zstdCompressSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + zstdDecompressStream({ maxOutputSize: 10 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Decompression"), `msg: ${e.message}`); + } + }); + + test(`${variant}: zstdCompressStream error message contains "Compression"`, async (_t) => { + try { + await pipeline([ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: 5 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("Compression"), `msg: ${e.message}`); + } + }); + + // Error message includes the maxOutputSize number (covers StringLiteral survivor). + test(`${variant}: gzipDecompressStream error message includes the byte limit value`, async (_t) => { + const input = gzipSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: 42 }), + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("42"), `msg: ${e.message}`); + } + }); +} + +// *** restore after "close" event (StringLiteral "close" → "") *** // +// After a stream closes normally, a second independent stream must still work. +if (variant === "node") { + test(`${variant}: gzipDecompressStream second invocation works after first closes`, async (_t) => { + const input = gzipSync(compressibleBody); + const out1 = await streamToString( + pipejoin([createReadableStream(input), gzipDecompressStream()]), + ); + strictEqual(out1, compressibleBody); + const out2 = await streamToString( + pipejoin([createReadableStream(input), gzipDecompressStream()]), + ); + strictEqual(out2, compressibleBody); + }); + + // After an error event fires the push override must be restored + // (StringLiteral "error" → ""). + test(`${variant}: gzipDecompressStream works correctly after previous maxOutputSize error`, async (_t) => { + const input = gzipSync(compressibleBody); + try { + await pipeline([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: 10 }), + ]); + } catch (_e) { + // expected + } + const out = await streamToString( + pipejoin([createReadableStream(input), gzipDecompressStream()]), + ); + strictEqual(out, compressibleBody); + }); +} + +// *** maxOutputSize: null on compress streams (LogicalOperator || survivor) *** // +// `||` mutation: `null !== null || null !== void 0` = true → guardOutput(null) → throws. +// These tests verify null disables the guard on compress streams. +if (variant === "node") { + test(`${variant}: gzipCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + gzipCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, gzipSync(compressibleBody).toString()); + }); + + test(`${variant}: deflateCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + deflateCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, deflateSync(compressibleBody).toString()); + }); + + test(`${variant}: brotliCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const out = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(brotliDecompressSync(out).toString(), compressibleBody); + }); + + test(`${variant}: zstdCompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const out = await streamToBuffer( + pipejoin([ + createReadableStream(compressibleBody), + zstdCompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(zstdDecompressSync(out).toString(), compressibleBody); + }); +} + +// *** zstd quality output size differs (kills ObjectLiteral `params ?? {}`) *** // +// With a 100KB repetitive body, quality:1 and quality:19 DO produce different sizes. +// `params ?? {}` loses the quality → both use default level → same output size. +if (variant === "node") { + const bigRepeat = "x".repeat(100_000); + test(`${variant}: zstdCompressStream quality:1 differs from quality:19 on large body`, async (_t) => { + const out1 = await streamToBuffer( + pipejoin([ + createReadableStream(bigRepeat), + zstdCompressStream({ quality: 1 }), + ]), + ); + const out19 = await streamToBuffer( + pipejoin([ + createReadableStream(bigRepeat), + zstdCompressStream({ quality: 19 }), + ]), + ); + ok( + out1.byteLength !== out19.byteLength, + `quality 1 (${out1.byteLength}B) must differ from quality 19 (${out19.byteLength}B)`, + ); + }); +} + +// *** default export usability (ObjectLiteral `default = {}` survivor) *** // +// The default export must expose compressStream/decompressStream functions; +// if the object is `{}` those calls would throw TypeError. +if (variant === "node") { + test(`${variant}: gzip default export compressStream/decompressStream round-trips`, async (_t) => { + const gzipMod = await import("@datastream/compress/gzip"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + gzipMod.default.compressStream(), + gzipMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); + + test(`${variant}: deflate default export compressStream/decompressStream round-trips`, async (_t) => { + const defMod = await import("@datastream/compress/deflate"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + defMod.default.compressStream(), + defMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); + + test(`${variant}: brotli default export compressStream/decompressStream round-trips`, async (_t) => { + const brotliMod = await import("@datastream/compress/brotli"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + brotliMod.default.compressStream(), + brotliMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); + + test(`${variant}: zstd default export compressStream/decompressStream round-trips`, async (_t) => { + const zstdMod = await import("@datastream/compress/zstd"); + const out = await streamToString( + pipejoin([ + createReadableStream(compressibleBody), + zstdMod.default.compressStream(), + zstdMod.default.decompressStream(), + ]), + ); + strictEqual(out, compressibleBody); + }); +} + +// *** AbortSignal honored on node compress/decompress streams *** // +// Aborting mid-flight must cause the pipeline to reject with an AbortError (the +// signal is forwarded into the underlying zlib stream); without signal support +// the pipeline would resolve successfully. A large payload + small chunkSize +// guarantees the stream is still in-flight when the signal fires. +const signalHonored = (e) => + e.name === "AbortError" || e.code === "ABORT_ERR" || /abort/i.test(e.message); +const bigBody = "x".repeat(2_000_000); +if (variant === "node") { + test(`${variant}: gzipCompressStream should reject when signal aborts`, async (_t) => { + const controller = new AbortController(); + const streams = [ + createReadableStream(bigBody, { chunkSize: 1024 }), + gzipCompressStream({}, { signal: controller.signal }), + ]; + const promise = pipeline(streams); + queueMicrotask(() => controller.abort()); + try { + await promise; + throw new Error("Should have thrown"); + } catch (e) { + ok(signalHonored(e), `${e.name}: ${e.message}`); + } + }); + + test(`${variant}: deflateDecompressStream should reject when signal aborts`, async (_t) => { + const controller = new AbortController(); + const input = deflateSync(bigBody); + const streams = [ + createReadableStream(input, { chunkSize: 1024 }), + deflateDecompressStream({}, { signal: controller.signal }), + ]; + const promise = pipeline(streams); + queueMicrotask(() => controller.abort()); + try { + await promise; + throw new Error("Should have thrown"); + } catch (e) { + ok(signalHonored(e), `${e.name}: ${e.message}`); + } + }); +} + +// *** maxOutputSize:null on decompress streams (ungated — works under both runs) *** // +// Covers the `maxOutputSize === null ? void 0 :` true-branch in each decompressor. +// The gzip variant is also covered inside the node-only block; these ungated +// copies ensure the branch fires in the webstream run too. +test(`${variant}: gzipDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = gzipSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + gzipDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); +}); + +test(`${variant}: deflateDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = deflateSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + deflateDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); +}); + +test(`${variant}: brotliDecompressStream maxOutputSize:null should NOT throw`, async (_t) => { + const input = brotliCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ maxOutputSize: null }), + ]), + ); + strictEqual(output, compressibleBody); +}); + +// *** guardOutput Buffer.byteLength(chunk) fallback (ungated — both runs) *** // +// guardOutput overrides stream.push and sizes each chunk via +// `chunk.byteLength ?? Buffer.byteLength(chunk)`. zlib only ever pushes Buffers +// (which have .byteLength), so the Buffer.byteLength fallback is reached only +// when a non-BufferSource (a string) is pushed. We grab the guarded stream and +// push a string directly: byteLength is undefined, so the fallback runs. +test(`${variant}: gzipDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = gzipDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); +}); + +test(`${variant}: deflateDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = deflateDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); +}); + +test(`${variant}: brotliDecompressStream guardOutput sizes string chunks via Buffer.byteLength`, (_t) => { + const stream = brotliDecompressStream({ maxOutputSize: 1024 }); + ok(stream.push("abc") === true); + stream.destroy(); +}); + +// String chunk larger than maxOutputSize must trip the guard: this exercises +// Buffer.byteLength(chunk) on the string-sizing path together with the +// `outputSize > maxOutputSize` true branch. push() returns false synchronously. +test(`${variant}: gzipDecompressStream guardOutput rejects oversized string chunk`, (_t) => { + const stream = gzipDecompressStream({ maxOutputSize: 2 }); + // Swallow the destroy() error so it does not surface as an unhandled error. + stream.on("error", () => {}); + strictEqual(stream.push("abcdef"), false); + stream.destroy(); +}); + if (variant === "webstream") { test(`${variant}: gzipDecompressStream should enforce maxOutputSize`, async (_t) => { const input = gzipSync(compressibleBody); @@ -379,4 +1070,563 @@ if (variant === "webstream") { ok(e.message.includes("maxOutputSize")); } }); + + // Brotli compress maxOutputSize — covers guardOutput invocation on the + // compress path (lines 39-41 in brotli.node.mjs) which is gated to node-only + // in the decompression-bomb block but must also fire in the webstream run. + test(`${variant}: brotliCompressStream should enforce maxOutputSize`, async (_t) => { + const streams = [ + createReadableStream(compressibleBody), + brotliCompressStream({ maxOutputSize: 5 }), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxOutputSize")); + } + }); + + // brotliDecompressStream with params — covers the `params ?` branch in + // brotliDecompressStream (line 46 in brotli.node.mjs). + test(`${variant}: brotliDecompressStream with params should round-trip`, async (_t) => { + const { constants: zlibConst } = await import("node:zlib"); + const input = brotliCompressSync(compressibleBody); + const output = await streamToString( + pipejoin([ + createReadableStream(input), + brotliDecompressStream({ + params: { [zlibConst.BROTLI_PARAM_MODE]: 0 }, + }), + ]), + ); + strictEqual(output, compressibleBody); + }); +} + +// *** web (*.web.js) sources, exercised directly *** // +// Node's package export resolution always matches the built-in `node` +// condition first, so `@datastream/compress/Core component of the datastream framework, the robust JavaScript streaming utility.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/core/index.node.js b/packages/core/index.node.js index f50d0bd..aba48e0 100644 --- a/packages/core/index.node.js +++ b/packages/core/index.node.js @@ -9,36 +9,70 @@ const NULL_SENTINEL = Symbol.for("@datastream/null"); const toSafe = (v) => (v === null ? NULL_SENTINEL : v); const fromSafe = (v) => (v === NULL_SENTINEL ? null : v); +// streamToObject accumulates onto an Object.create(null) and previously +// returned `{ ...value }`. If any chunk carried an own `__proto__` key (e.g. +// from JSON.parse of untrusted input), the spread copied it as an own +// enumerable `__proto__` data property on the returned plain object — a +// surprising contract that confuses prototype-based checks. Strip any own +// `__proto__` key and return a plain object with a normal prototype. +const sanitizeObject = (value) => { + const out = {}; + for (const key of Object.keys(value)) { + if (key === "__proto__") continue; + out[key] = value[key]; + } + return out; +}; + export const pipeline = async (streams, streamOptions = {}) => { for (let idx = 0, l = streams.length; idx < l; idx++) { if (typeof streams[idx].then === "function") { throw new Error(`Promise instead of stream passed in at index ${idx}`); } } + // Work on copies so appending the terminal writable (and deriving its + // objectMode) doesn't mutate the caller's array or options object. + streams = [...streams]; // Ensure stream ends with only writable const lastStream = streams[streams.length - 1]; if (isReadable(lastStream)) { - streamOptions.objectMode = lastStream._readableState.objectMode; + streamOptions = { + ...streamOptions, + objectMode: lastStream._readableState.objectMode, + }; streams.push(createWritableStream(() => {}, streamOptions)); } await pipelinePromise(streams, streamOptions); return result(streams); }; -export const pipejoin = ( - streams, - onError = (e) => { - process.nextTick(() => { - throw e; - }); - }, -) => { - const pipeline = streams.reduce((pipeline, stream, idx) => { - if (typeof stream.then === "function") { +export const pipejoin = (streams, onError) => { + for (let idx = 0, l = streams.length; idx < l; idx++) { + if (typeof streams[idx].then === "function") { throw new Error(`Promise instead of stream passed in at index ${idx}`); } - return pipeline.pipe(stream).on("error", onError); - }); + } + let settled = false; + const teardown = (error) => { + if (settled) return; + settled = true; + for (const stream of streams) { + if (!stream.destroyed) stream.destroy(error); + } + if (onError) { + onError(error); + } else { + process.nextTick(() => { + throw error; + }); + } + }; + let pipeline = streams[0]; + pipeline.on("error", teardown); + for (let idx = 1, l = streams.length; idx < l; idx++) { + pipeline = pipeline.pipe(streams[idx]); + pipeline.on("error", teardown); + } return pipeline; }; @@ -73,32 +107,44 @@ export const backpressureGauge = (streams) => { // Number.parseInt( (process.hrtime.bigint() - pauseTimestamp).toString() , 10 ) / 1_000_000 // ms const duration = Date.now() - timestamp; metrics[keys[i]].timeline.push({ timestamp, duration }); + // Clear so a second resume without an intervening pause can't + // record a phantom interval from the stale pause timestamp. + timestamp = undefined; } }); - value.on("end", () => { + // Readable streams emit 'end'; writable (sink) streams emit 'finish' + // and never 'end', so listen for both (plus 'close' as a backstop) to + // record total duration for writable/duplex nodes too. Guard against + // double-recording when more than one terminal event fires. + const recordTotal = () => { + if (metrics[keys[i]].total.timestamp != null) return; const duration = Date.now() - startTimestamp; metrics[keys[i]].total = { timestamp: startTimestamp, duration }; - }); + }; + value.on("end", recordTotal); + value.on("finish", recordTotal); + value.on("close", recordTotal); } return metrics; }; -export const streamToArray = (stream, { maxBufferSize } = {}) => { +export const streamToArray = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const value = []; let size = 0; stream.on("data", (chunk) => { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } value.push(fromSafe(chunk)); }); @@ -112,41 +158,43 @@ export const streamToArray = (stream, { maxBufferSize } = {}) => { const value = []; let size = 0; for await (const chunk of stream) { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - throw new Error( - `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + throw new Error( + `streamToArray buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } - value.push(chunk); + // Decode the null sentinel here too so both consumption paths agree. + value.push(fromSafe(chunk)); } return value; })(); }; -export const streamToObject = (stream, { maxBufferSize } = {}) => { +export const streamToObject = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const value = Object.create(null); let size = 0; stream.on("data", (chunk) => { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } - Object.assign(value, chunk); + // Unwrap the null sentinel so a null-bearing object stream doesn't + // leak the Symbol; Object.assign(value, null) is a safe no-op. + Object.assign(value, fromSafe(chunk)); }); stream.on("end", () => { - resolve({ ...value }); + resolve(sanitizeObject(value)); }); stream.on("error", reject); }); @@ -155,38 +203,39 @@ export const streamToObject = (stream, { maxBufferSize } = {}) => { const value = Object.create(null); let size = 0; for await (const chunk of stream) { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 1; - if (size > maxBufferSize) { - throw new Error( - `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + size += chunk?.length ?? chunk?.byteLength ?? 1; + if (size > maxBufferSize) { + throw new Error( + `streamToObject buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } - Object.assign(value, chunk); + Object.assign(value, fromSafe(chunk)); } - return { ...value }; + return sanitizeObject(value); })(); }; -export const streamToString = (stream, { maxBufferSize } = {}) => { +export const streamToString = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const chunks = []; let size = 0; stream.on("data", (chunk) => { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 0; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + size += chunk?.length ?? chunk?.byteLength ?? 0; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } - chunks.push(chunk); + // Unwrap the null sentinel; otherwise join("") throws + // "Cannot convert a Symbol value to a string". + chunks.push(fromSafe(chunk)); }); stream.on("end", () => { resolve(chunks.join("")); @@ -198,37 +247,38 @@ export const streamToString = (stream, { maxBufferSize } = {}) => { const chunks = []; let size = 0; for await (const chunk of stream) { - if (maxBufferSize != null) { - size += chunk?.length ?? chunk?.byteLength ?? 0; - if (size > maxBufferSize) { - throw new Error( - `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + size += chunk?.length ?? chunk?.byteLength ?? 0; + if (size > maxBufferSize) { + throw new Error( + `streamToString buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } - chunks.push(chunk); + chunks.push(fromSafe(chunk)); } return chunks.join(""); })(); }; -export const streamToBuffer = (stream, { maxBufferSize } = {}) => { +export const streamToBuffer = ( + stream, + { maxBufferSize = Number.POSITIVE_INFINITY } = {}, +) => { if (typeof stream.on === "function") { return new Promise((resolve, reject) => { const value = []; let size = 0; stream.on("data", (chunk) => { - const buf = Buffer.from(chunk); - if (maxBufferSize != null) { - size += buf.length; - if (size > maxBufferSize) { - stream.destroy( - new Error( - `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, - ), - ); - return; - } + // Unwrap the null sentinel; Buffer.from(symbol) throws + // ERR_INVALID_ARG_TYPE. fromSafe(null) -> null -> empty buffer. + const buf = Buffer.from(fromSafe(chunk) ?? []); + size += buf.length; + if (size > maxBufferSize) { + stream.destroy( + new Error( + `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, + ), + ); + return; } value.push(buf); }); @@ -242,14 +292,12 @@ export const streamToBuffer = (stream, { maxBufferSize } = {}) => { const value = []; let size = 0; for await (const chunk of stream) { - const buf = Buffer.from(chunk); - if (maxBufferSize != null) { - size += buf.length; - if (size > maxBufferSize) { - throw new Error( - `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, - ); - } + const buf = Buffer.from(fromSafe(chunk) ?? []); + size += buf.length; + if (size > maxBufferSize) { + throw new Error( + `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, + ); } value.push(buf); } @@ -309,7 +357,7 @@ export const createReadableStream = (input, streamOptions = {}) => { if (typeof input === "string") { return createReadableStreamFromString(input, streamOptions); } - if (typeof input === "object" && input.byteLength) { + if (input.byteLength) { return createReadableStreamFromArrayBuffer(input, streamOptions); } if (Array.isArray(input)) { @@ -520,18 +568,13 @@ export const timeout = (ms, { signal } = {}) => { ); } return new Promise((resolve, reject) => { - let settled = false; const abortHandler = () => { - if (settled) return; - settled = true; clearTimeout(timerId); signal.removeEventListener("abort", abortHandler); reject(new Error("Aborted", { cause: { code: "AbortError" } })); }; if (signal) signal.addEventListener("abort", abortHandler); const timerId = setTimeout(() => { - if (settled) return; - settled = true; if (signal) signal.removeEventListener("abort", abortHandler); resolve(); }, ms); diff --git a/packages/core/index.test.js b/packages/core/index.test.js index f1a3c8f..a6d4d57 100644 --- a/packages/core/index.test.js +++ b/packages/core/index.test.js @@ -1,4 +1,6 @@ import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; import test, { mock } from "node:test"; import { backpressureGauge, @@ -165,6 +167,22 @@ test(`${variant}: streamToBuffer should work with Uint8Array`, async (_t) => { deepStrictEqual(output.toString(), "hello"); }); +// The bare `@datastream/core` import resolves to the node build under both +// --conditions (Node always injects the `node` condition first), so the web +// build is only actually exercised by importing it directly. streamToBuffer +// was missing from the web build entirely; assert parity here. +test(`${variant}: web build exports a working streamToBuffer`, async (_t) => { + const web = await import( + `file://${new URL("./index.web.js", import.meta.url).pathname}` + ); + strictEqual(typeof web.streamToBuffer, "function"); + const out = await web.streamToBuffer( + web.createReadableStream(["hello", " ", "world"]), + ); + ok(out instanceof Uint8Array); + strictEqual(new TextDecoder().decode(out), "hello world"); +}); + // *** backpressureGauge *** // test(`${variant}: backpressureGauge should measure stream metrics`, async (_t) => { const input = ["a", "b", "c"]; @@ -219,6 +237,19 @@ test(`${variant}: backpressureGauge should track resume without prior pause`, as strictEqual(typeof metrics.transform.total.duration, "number"); }); +test(`${variant}: backpressureGauge records one interval per pause/resume pair`, async (_t) => { + const transform = createTransformStream(); + const metrics = backpressureGauge({ transform }); + + // One real pause/resume pair, then a stray resume with no intervening pause. + transform.emit("pause"); + transform.emit("resume"); + transform.emit("resume"); + + // The stray resume must not record a phantom interval from the stale pause. + strictEqual(metrics.transform.timeline.length, 1); +}); + // *** createReadableStream *** // test(`${variant}: createReadableStream should create a readable stream from string`, async (_t) => { const input = "abc"; @@ -286,6 +317,20 @@ test(`${variant}: createReadableStream should allow pushing values onto it`, asy deepStrictEqual(output, ["a"]); }); +test(`${variant}: createReadableStream read() is invoked when consumer requests data before push`, async (_t) => { + // Start consuming BEFORE pushing so the Readable's _read() hook is triggered + // when the stream is in flowing mode but the buffer is empty. + const source = createReadableStream(); + const outputPromise = streamToArray(source); + // Push data asynchronously so _read() fires first. + process.nextTick(() => { + source.push("x"); + source.push(null); + }); + const output = await outputPromise; + deepStrictEqual(output, ["x"]); +}); + if (variant === "node") { const { backpressureGauge } = await import("@datastream/core"); test(`${variant}: backpressureGauge should chunk really long strings`, async (_t) => { @@ -811,20 +856,18 @@ test(`${variant}: streamToBuffer should throw when exceeding maxBufferSize`, asy }); // *** createReadableStream queue limit regression *** // -if (variant === "node") { - test(`${variant}: createReadableStream should throw when queue exceeds limit`, async (_t) => { - const stream = createReadableStream(undefined, { highWaterMark: 3 }); - stream.push("a"); - stream.push("b"); - stream.push("c"); - try { - stream.push("d"); - throw new Error("Should have thrown"); - } catch (e) { - ok(e.message.includes("exceeds limit")); - } - }); -} +test(`${variant}: createReadableStream should throw when queue exceeds limit`, async (_t) => { + const stream = createReadableStream(undefined, { highWaterMark: 3 }); + stream.push("a"); + stream.push("b"); + stream.push("c"); + try { + stream.push("d"); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("exceeds limit")); + } +}); // *** deepClone / deepEqual error paths *** // test(`${variant}: deepClone throws for non-cloneable values`, () => { @@ -884,3 +927,1479 @@ test(`${variant}: deepEqual via JSON serialization`, () => { strictEqual(deepEqual({ a: { b: 1 } }, { a: { b: 1 } }), true); strictEqual(deepEqual({ a: { b: 1 } }, { a: { b: 2 } }), false); }); + +// *** Node NULL_SENTINEL collector regression *** // +// The documented way to flow a null through a node object-mode stream is +// enqueue(null), which createTransformStream wraps as NULL_SENTINEL. Every +// collector must unwrap it via fromSafe, not just streamToArray. +test(`${variant}: streamToArray unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => enqueue(null)), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [null]); +}); + +test(`${variant}: streamToString unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => enqueue(null)), + ]; + const output = await streamToString(pipejoin(streams)); + // fromSafe(null) -> null; "".join semantics turn null into an empty string. + strictEqual(output, ""); +}); + +test(`${variant}: streamToObject unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => { + enqueue({ x: 1 }); + enqueue(null); + }), + ]; + // Object.assign(value, null) is a no-op, so the null chunk must not throw. + const output = await streamToObject(pipejoin(streams)); + deepStrictEqual(output, { x: 1 }); +}); + +test(`${variant}: streamToBuffer unwraps NULL_SENTINEL from object stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => { + enqueue("hi"); + enqueue(null); + }), + ]; + // Buffer.from(null) throws; fromSafe(null) must yield an empty buffer. + const output = await streamToBuffer(pipejoin(streams)); + strictEqual(output.toString(), "hi"); +}); + +// *** Node backpressureGauge writable total regression *** // +if (variant === "node") { + test(`${variant}: backpressureGauge records total for writable sinks`, async (_t) => { + const writable = createWritableStream(); + const streams = { + readable: createReadableStream(["a", "b", "c"]), + writable, + }; + const metrics = backpressureGauge(streams); + await pipeline(Object.values(streams)); + // Writable streams emit 'finish'/'close', not 'end', so total must be + // recorded from those lifecycle events too. + strictEqual(typeof metrics.writable.total.timestamp, "number"); + strictEqual(typeof metrics.writable.total.duration, "number"); + }); +} + +// *** Node streamToObject __proto__ contract regression *** // +test(`${variant}: streamToObject does not expose own __proto__ data key`, async (_t) => { + // JSON.parse produces an own __proto__ key; the accumulator must not surface + // it as an own enumerable property on the returned object. + const evil = JSON.parse('{"__proto__":{"polluted":true},"safe":1}'); + const streams = [ + createReadableStream(["a"]), + createTransformStream((_chunk, enqueue) => enqueue(evil)), + ]; + const output = await streamToObject(pipejoin(streams)); + strictEqual(Object.hasOwn(output, "__proto__"), false); + strictEqual(output.safe, 1); + // And no global prototype pollution occurred. + strictEqual({}.polluted, undefined); +}); + +// *** Web build direct-import regressions *** // +// The bare `@datastream/core` import always resolves to the node build, so the +// web source is exercised only by importing it directly here. +const loadWeb = () => + import(`file://${new URL("./index.web.js", import.meta.url).pathname}`); + +test(`${variant}: web streamToString decodes byte chunks instead of comma-joining`, async (_t) => { + const web = await loadWeb(); + async function* gen() { + yield new TextEncoder().encode("hi"); + yield new TextEncoder().encode("!"); + } + const output = await web.streamToString(gen()); + strictEqual(output, "hi!"); +}); + +test(`${variant}: web streamToString decodes multibyte split across chunks`, async (_t) => { + const web = await loadWeb(); + const bytes = new TextEncoder().encode("é"); // 2 bytes: 0xC3 0xA9 + async function* gen() { + yield bytes.subarray(0, 1); + yield bytes.subarray(1); + } + const output = await web.streamToString(gen()); + strictEqual(output, "é"); +}); + +test(`${variant}: web streamToString still joins string chunks`, async (_t) => { + const web = await loadWeb(); + async function* gen() { + yield "hello"; + yield " world"; + } + const output = await web.streamToString(gen()); + strictEqual(output, "hello world"); +}); + +test(`${variant}: web createReadableStream honors typed-array byteOffset/byteLength`, async (_t) => { + const web = await loadWeb(); + // A subarray view over a larger buffer must stream only its own window, + // not the whole backing ArrayBuffer (adjacent-heap leak otherwise). + const full = new Uint8Array([0, 0, 1, 2, 3, 0, 0]); + const view = full.subarray(2, 5); // [1,2,3] + const out = await web.streamToBuffer(web.createReadableStream(view)); + deepStrictEqual(Array.from(out), [1, 2, 3]); +}); + +test(`${variant}: web createReadableStream honors Buffer byteOffset/byteLength`, async (_t) => { + const web = await loadWeb(); + // Node Buffers share a pooled allocation; reading the whole backing buffer + // would leak pooled bytes and yield far more than 5 bytes. + const buf = Buffer.from([1, 2, 3, 4, 5]); + const out = await web.streamToBuffer(web.createReadableStream(buf)); + deepStrictEqual(Array.from(out), [1, 2, 3, 4, 5]); +}); + +test(`${variant}: web create*Stream remove the abort listener on error`, async (_t) => { + const web = await loadWeb(); + const controller = new AbortController(); + const { signal } = controller; + const before = signal.removeEventListener; + let removed = 0; + // Count removeEventListener calls for 'abort' to detect listener cleanup. + signal.removeEventListener = function (type, ...rest) { + if (type === "abort") removed += 1; + return before.call(this, type, ...rest); + }; + const streams = [ + web.createReadableStream(["a", "b", "c"]), + web.createTransformStream( + () => { + throw new Error("boom"); + }, + { signal }, + ), + web.createWritableStream(() => {}, { signal }), + ]; + try { + await web.pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "boom"); + } + // Both the transform and the writable registered an abort listener; both + // must be removed on the error path. + ok(removed >= 2, `expected >=2 abort listener removals, got ${removed}`); +}); + +// Race against a short timeout: an unsupported input must error promptly, not +// hang the stream forever (which would otherwise stall the whole test run). +const withTimeout = (promise, ms = 1000) => + Promise.race([ + promise, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error("hung: stream never settled")), ms), + ), + ]); + +test(`${variant}: web createReadableStream errors on unsupported scalar input`, async (_t) => { + const web = await loadWeb(); + try { + await withTimeout(web.streamToArray(web.createReadableStream(42))); + throw new Error("Should have thrown"); + } catch (e) { + ok(e instanceof TypeError, `expected TypeError, got ${e}`); + ok(e.message.includes("unsupported input")); + } +}); + +test(`${variant}: web createReadableStream errors clearly on null input`, async (_t) => { + const web = await loadWeb(); + try { + await withTimeout(web.streamToArray(web.createReadableStream(null))); + throw new Error("Should have thrown"); + } catch (e) { + ok(e instanceof TypeError, `expected TypeError, got ${e}`); + ok(e.message.includes("unsupported input")); + } +}); + +test(`${variant}: web isReadable/isWritable return false for null/undefined`, async (_t) => { + const web = await loadWeb(); + strictEqual(web.isReadable(null), false); + strictEqual(web.isReadable(undefined), false); + strictEqual(web.isWritable(null), false); + strictEqual(web.isWritable(undefined), false); + // And primitives must not throw either. + strictEqual(web.isReadable(42), false); + strictEqual(web.isWritable("x"), false); +}); + +// =========================================================================== +// *** Mutation-killing tests (node build) *** +// Each test below pins a specific behavior so a one-line mutation of the +// source would flip an assertion. Grouped by the source construct targeted. +// =========================================================================== + +// --- EventEmitter-only collector path (the `typeof stream.on === "function"` +// branch). A Node Readable is also async-iterable, so deleting the .on branch +// still works via the async path. A bare EventEmitter is NOT async-iterable, so +// only the .on path can drain it; this distinguishes the two code paths. --- +const emitterStream = (chunks) => { + const ee = new EventEmitter(); + // Not async-iterable on purpose: no Symbol.asyncIterator, no .pipe. + process.nextTick(() => { + for (const c of chunks) ee.emit("data", c); + ee.emit("end"); + }); + return ee; +}; + +if (variant === "node") { + test(`${variant}: streamToArray uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToArray(emitterStream(["a", "b", "c"])); + deepStrictEqual(out, ["a", "b", "c"]); + }); + + test(`${variant}: streamToString uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToString(emitterStream(["a", "b", "c"])); + strictEqual(out, "abc"); + }); + + test(`${variant}: streamToObject uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToObject(emitterStream([{ a: 1 }, { b: 2 }])); + deepStrictEqual(out, { a: 1, b: 2 }); + }); + + test(`${variant}: streamToBuffer uses the .on path for plain EventEmitters`, async (_t) => { + const out = await streamToBuffer( + emitterStream([Buffer.from("ab"), Buffer.from("c")]), + ); + strictEqual(out.toString(), "abc"); + }); + + // The .on path must reject on 'error' (kills deletion of stream.on("error")). + test(`${variant}: streamToArray .on path rejects on error event`, async (_t) => { + const ee = new EventEmitter(); + ee.destroy = () => {}; + process.nextTick(() => ee.emit("error", new Error("boom"))); + try { + await withTimeout(streamToArray(ee)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "boom"); + } + }); +} + +// --- maxBufferSize boundary precision for every collector. The threshold is a +// strict `>` and the running total is an ADDITION. These pin `>` (not >=, <=), +// the `+=` (not -=), and that the boundary value itself does NOT throw. --- + +// streamToArray: object-mode length fallback is `?? 1` per chunk. +test(`${variant}: streamToArray counts each non-sized chunk as 1 (?? 1 fallback)`, async (_t) => { + // 3 plain objects -> size 3. maxBufferSize 3 must pass (boundary, not > ). + const ok3 = await streamToArray(createReadableStream([{}, {}, {}]), { + maxBufferSize: 3, + }); + strictEqual(ok3.length, 3); + // A 4th identical chunk -> size 4 > 3 must throw (kills `?? 0`, `-=`, `>=`). + try { + await streamToArray(createReadableStream([{}, {}, {}, {}]), { + maxBufferSize: 3, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToArray at exact maxBufferSize boundary does not throw`, async (_t) => { + // "aaa"+"bbb" = 6 chars; maxBufferSize 6 is the boundary and must pass. + const out = await streamToArray(createReadableStream(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + deepStrictEqual(out, ["aaa", "bbb"]); +}); + +test(`${variant}: streamToArray uses byteLength when length is absent`, async (_t) => { + // Uint8Array has byteLength but no string length; size must accrue 5. + const chunk = Uint8Array.from([1, 2, 3, 4, 5]); + try { + await streamToArray(createReadableStream([chunk]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + // And 5 (exact) passes. + const out = await streamToArray(createReadableStream([chunk]), { + maxBufferSize: 5, + }); + strictEqual(out.length, 1); +}); + +test(`${variant}: streamToString at exact boundary passes, one over throws`, async (_t) => { + const ok6 = await streamToString(createReadableStream(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + strictEqual(ok6, "aaabbb"); + try { + await streamToString(createReadableStream(["aaa", "bbbc"]), { + maxBufferSize: 6, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToString non-sized chunk fallback is 0, not 1`, async (_t) => { + // streamToString uses `?? 0`: an object with no length contributes 0, so a + // tiny maxBufferSize must NOT trip on object-mode chunks. (Kills `?? 1`.) + const out = await streamToString(createReadableStream([{}, {}, {}]), { + maxBufferSize: 0, + }); + // String(...) of objects joined; just assert it did not throw. + strictEqual(typeof out, "string"); +}); + +test(`${variant}: streamToObject at exact boundary passes, one over throws`, async (_t) => { + // Each object counts as 1 (?? 1). 3 objects, boundary 3 passes. + const ok3 = await streamToObject( + createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }]), + { maxBufferSize: 3 }, + ); + deepStrictEqual(ok3, { a: 1, b: 2, c: 3 }); + try { + await streamToObject( + createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]), + { maxBufferSize: 3 }, + ); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToBuffer at exact boundary passes, one over throws`, async (_t) => { + const ok6 = await streamToBuffer( + createReadableStream([Buffer.from("aaa"), Buffer.from("bbb")]), + { maxBufferSize: 6 }, + ); + strictEqual(ok6.toString(), "aaabbb"); + try { + await streamToBuffer( + createReadableStream([Buffer.from("aaa"), Buffer.from("bbbc")]), + { maxBufferSize: 6 }, + ); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// The maxBufferSize error messages must name the collector and the limit value +// (kills StringLiteral -> "" on each `buffer exceeds maxBufferSize (...)`). +test(`${variant}: maxBufferSize errors carry collector name and limit`, async (_t) => { + const cases = [ + [streamToArray, ["aaa", "bbb", "ccc"], "streamToArray"], + [streamToString, ["aaa", "bbb", "ccc"], "streamToString"], + [streamToBuffer, [Buffer.from("aaaaaaa")], "streamToBuffer"], + ]; + for (const [fn, input, name] of cases) { + try { + await fn(createReadableStream(input), { maxBufferSize: 6 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes(name), `expected ${name} in: ${e.message}`); + ok(e.message.includes("6"), `expected limit 6 in: ${e.message}`); + } + } + try { + await streamToObject(createReadableStream([{ a: 1 }, { b: 2 }, { c: 3 }]), { + maxBufferSize: 2, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("streamToObject")); + ok(e.message.includes("2")); + } +}); + +// --- async-iterable path also enforces maxBufferSize boundary (covers the +// second copy of each guard in the for-await branch). --- +const asyncGen = (chunks) => + (async function* () { + for (const c of chunks) yield c; + })(); + +test(`${variant}: streamToArray async path enforces boundary precisely`, async (_t) => { + const out = await streamToArray(asyncGen(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + deepStrictEqual(out, ["aaa", "bbb"]); + try { + await streamToArray(asyncGen(["aaa", "bbbc"]), { maxBufferSize: 6 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToString async path enforces boundary precisely`, async (_t) => { + const out = await streamToString(asyncGen(["aaa", "bbb"]), { + maxBufferSize: 6, + }); + strictEqual(out, "aaabbb"); + try { + await streamToString(asyncGen(["aaa", "bbbc"]), { maxBufferSize: 6 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToObject async path enforces boundary precisely`, async (_t) => { + const out = await streamToObject(asyncGen([{ a: 1 }, { b: 2 }, { c: 3 }]), { + maxBufferSize: 3, + }); + deepStrictEqual(out, { a: 1, b: 2, c: 3 }); + try { + await streamToObject(asyncGen([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]), { + maxBufferSize: 3, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToBuffer async path enforces boundary precisely`, async (_t) => { + const out = await streamToBuffer( + asyncGen([Buffer.from("aaa"), Buffer.from("bbb")]), + { maxBufferSize: 6 }, + ); + strictEqual(out.toString(), "aaabbb"); + try { + await streamToBuffer(asyncGen([Buffer.from("aaa"), Buffer.from("bbbc")]), { + maxBufferSize: 6, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// streamToObject async byteLength fallback (Uint8Array chunks count by bytes). +test(`${variant}: streamToObject byteLength fallback counts bytes`, async (_t) => { + // A typed array of 5 bytes -> size 5 > 4 must throw even in object mode. + const chunk = Uint8Array.from([1, 2, 3, 4, 5]); + try { + await streamToObject(createReadableStream([chunk]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// The size fallback is `length ?? byteLength ?? N`, NOT `length && byteLength`. +// A chunk with NO `length` but a truthy `byteLength` must count by byteLength. +// Under the `&&` (LogicalOperator) mutant, `undefined && byteLength` is +// undefined, so the fallback collapses to N (1 or 0) and the limit is not hit. +// Use a plain object exposing only `byteLength` so `?.length` is undefined. +const byteLenChunk = (n, extra = {}) => ({ byteLength: n, ...extra }); + +test(`${variant}: streamToArray counts a byteLength-only chunk by its byteLength`, async (_t) => { + // .on path: size 5 > 4 must throw (kills `?? -> &&` at the .on guard). + try { + await streamToArray(createReadableStream([byteLenChunk(5)]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + // async path: same expectation. + try { + await streamToArray(asyncGen([byteLenChunk(5)]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToObject counts a byteLength-only chunk by its byteLength`, async (_t) => { + try { + await streamToObject(createReadableStream([byteLenChunk(5, { a: 1 })]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + try { + await streamToObject(asyncGen([byteLenChunk(5, { a: 1 })]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +test(`${variant}: streamToString counts a byteLength-only chunk by its byteLength`, async (_t) => { + // streamToString fallback is `?? 0`; a byteLength-only chunk of 5 still + // exceeds maxBufferSize 4 via byteLength (kills `?? -> &&`). + try { + await streamToString(createReadableStream([byteLenChunk(5)]), { + maxBufferSize: 4, + }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + try { + await streamToString(asyncGen([byteLenChunk(5)]), { maxBufferSize: 4 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } +}); + +// --- async path `?? N` size fallbacks when chunks have neither length nor +// byteLength (kills `?? 1` / `?? 0` in the for-await branch of each collector). --- + +test(`${variant}: streamToArray async path ?? 1 fallback counts no-size chunks`, async (_t) => { + // A plain object has no length/byteLength; ?? 1 makes it count as 1. + // 4 such chunks -> size 4 > 3 must throw (kills the ?? 1 deletion mutant). + try { + await streamToArray(asyncGen([{}, {}, {}, {}]), { maxBufferSize: 3 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("maxBufferSize")); + } + // 3 chunks -> size 3, boundary passes. + const out = await streamToArray(asyncGen([{}, {}, {}]), { maxBufferSize: 3 }); + strictEqual(out.length, 3); +}); + +test(`${variant}: streamToString async path ?? 0 fallback does not count no-size chunks`, async (_t) => { + // streamToString uses ?? 0 for chunks without length/byteLength. + // Even with maxBufferSize: 0, plain-object chunks must NOT trip the limit. + const out = await streamToString(asyncGen([{}, {}]), { maxBufferSize: 0 }); + strictEqual(typeof out, "string"); +}); + +test(`${variant}: streamToBuffer async path handles null-sentinel chunk`, async (_t) => { + // The async path of streamToBuffer wraps each chunk with fromSafe(...) ?? []. + // Yielding the NULL_SENTINEL symbol triggers fromSafe -> null -> [] -> empty Buffer. + const NULL_SENTINEL = Symbol.for("@datastream/null"); + async function* gen() { + yield "hi"; + yield NULL_SENTINEL; + } + const out = await streamToBuffer(gen()); + // Only "hi" contributes bytes; the null-sentinel chunk becomes an empty buffer. + strictEqual(out.toString(), "hi"); +}); + +// --- NULL_SENTINEL round-trip: a transform enqueue(null) flows through as null, +// AND a literal pushed null is treated as EOF (does not appear). --- +test(`${variant}: createReadableStream array null becomes a flowing null value`, async (_t) => { + // toSafe maps array `null` -> NULL_SENTINEL so it is not interpreted as EOF; + // the collector's fromSafe maps it back to null. + const out = await streamToArray(createReadableStream(["a", null, "b"])); + deepStrictEqual(out, ["a", null, "b"]); +}); + +// --- sanitizeObject: own-enumerable __proto__ key must be stripped. Use a +// literal own-enumerable key via Object.defineProperty so the +// `key === "__proto__"` guard is exercised through Object.keys. --- +test(`${variant}: streamToObject strips an own-enumerable __proto__ key`, async (_t) => { + const evil = {}; + Object.defineProperty(evil, "__proto__", { + value: { polluted: true }, + enumerable: true, + configurable: true, + writable: true, + }); + Object.defineProperty(evil, "safe", { + value: 1, + enumerable: true, + configurable: true, + writable: true, + }); + strictEqual(Object.keys(evil).includes("__proto__"), true); + const out = await streamToObject(asyncGen([evil])); + strictEqual(Object.hasOwn(out, "__proto__"), false); + strictEqual(out.safe, 1); + // Without the `key === "__proto__"` guard, `out["__proto__"] = ...` would + // replace out's prototype (out is a plain `{}`), leaking `polluted` and + // changing the prototype. The guard must keep a clean Object.prototype. + strictEqual(Object.getPrototypeOf(out), Object.prototype); + strictEqual(out.polluted, undefined); +}); + +// --- pipeline: lastStream index is length - 1 (kills `+ 1`). A single pure +// Readable needs an appended writable sink to terminate; with `+ 1` the index +// is undefined, isReadable(undefined) is false, no sink is appended, and +// pipelinePromise([readable]) rejects with "streams argument must be specified". +// This also kills the isReadable(lastStream) branch deletion. --- +test(`${variant}: pipeline appends a sink for a single pure readable`, async (_t) => { + const out = await withTimeout( + pipeline([createReadableStream(["a", "b", "c"])]), + ); + deepStrictEqual(out, {}); +}); + +// --- pipeline promise guard loop must actually iterate (kills loop `false`, +// `idx >= l`, block deletion, the `typeof ... then` check, and the index in the +// message). A promise at index 2 must be reported with that index. --- +test(`${variant}: pipeline reports the index of a promise-instead-of-stream`, async (_t) => { + const streams = [ + createReadableStream(["a"]), + createTransformStream(), + Promise.resolve(createTransformStream()), + ]; + try { + await pipeline(streams); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "Promise instead of stream passed in at index 2"); + } +}); + +// --- pipejoin forwards stream errors to the onError callback (kills onError +// block deletion / process.nextTick block deletion and the on("error") +// string mutant). --- +test(`${variant}: pipejoin error handler receives error on the right event`, async (_t) => { + let got; + const streams = [ + createReadableStream(["a"]), + createTransformStream(() => { + throw new Error("boom2"); + }), + ]; + pipejoin(streams, (e) => { + got = e.message; + }); + await timeout(50); + strictEqual(got, "boom2"); +}); + +// pipejoin's DEFAULT onError rethrows asynchronously via process.nextTick. +// Deleting that body would silently swallow stream errors. Capture the rethrow +// through an uncaughtException listener (kills the onError default-block and the +// process.nextTick block deletions). Listeners are restored when done. +test(`${variant}: pipejoin default onError rethrows the error`, async (_t) => { + let caught; + const handler = (e) => { + caught = e.message; + }; + // Temporarily own uncaughtException so the async rethrow is observable + // without crashing the test process. + const prior = process.listeners("uncaughtException"); + for (const l of prior) process.removeListener("uncaughtException", l); + process.on("uncaughtException", handler); + try { + const streams = [ + createReadableStream(["a"]), + createTransformStream(() => { + throw new Error("default-rethrow"); + }), + ]; + const joined = pipejoin(streams); // default onError + joined.on("error", () => {}); + joined.resume(); + await timeout(100); + strictEqual(caught, "default-rethrow"); + } finally { + process.removeListener("uncaughtException", handler); + for (const l of prior) process.on("uncaughtException", l); + } +}); + +// --- createReadableStream branch coverage --- + +// Array.isArray branch: array maps null -> sentinel so an embedded null is +// preserved as a value (raw null would be EOF). (kills branch deletion / +// `false` / `true`). +test(`${variant}: createReadableStream array branch preserves embedded null`, async (_t) => { + const out = await streamToArray(createReadableStream([null, "x"])); + deepStrictEqual(out, [null, "x"]); +}); + +// object-with-byteLength branch (ArrayBuffer) yields a single Uint8Array chunk. +test(`${variant}: createReadableStream routes ArrayBuffer to byte chunker`, async (_t) => { + const buf = new Uint8Array([9, 8, 7]).buffer; + const out = await streamToArray(createReadableStream(buf)); + strictEqual(out.length, 1); + deepStrictEqual(Array.from(out[0]), [9, 8, 7]); +}); + +// String routing splits by chunkSize via substring (kills MethodExpression +// yield input2 and the position arithmetic). +test(`${variant}: createReadableStream chunks strings by chunkSize via substring`, async (_t) => { + const input = "abcdefgh"; + const out = await streamToArray( + createReadableStream(input, { chunkSize: 3 }), + ); + deepStrictEqual(out, ["abc", "def", "gh"]); +}); + +test(`${variant}: createReadableStream string chunk boundary is exact`, async (_t) => { + // length 6, chunkSize 3 -> exactly 2 full chunks, no empty trailing chunk + // (kills while `<=` which would emit an extra empty "" at position===length). + const out = await streamToArray( + createReadableStream("abcdef", { chunkSize: 3 }), + ); + deepStrictEqual(out, ["abc", "def"]); +}); + +// chunkSize <= 0 must throw (kills `<= 0` -> `< 0`, `false`, and message ""). +test(`${variant}: createReadableStream rejects zero chunkSize for strings`, (_t) => { + try { + createReadableStream("abc", { chunkSize: 0 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +test(`${variant}: createReadableStream rejects negative chunkSize for strings`, (_t) => { + try { + createReadableStream("abc", { chunkSize: -1 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +test(`${variant}: createReadableStream rejects zero chunkSize for ArrayBuffer`, (_t) => { + try { + createReadableStream(new Uint8Array([1, 2, 3]).buffer, { chunkSize: 0 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +test(`${variant}: createReadableStream rejects negative chunkSize for ArrayBuffer`, (_t) => { + try { + createReadableStream(new Uint8Array([1, 2, 3]).buffer, { chunkSize: -2 }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("positive number")); + } +}); + +// ArrayBuffer chunking by size (kills while/position mutants and the subarray +// window arithmetic in the arraybuffer iterator). +if (variant === "node") { + test(`${variant}: createReadableStream chunks ArrayBuffer by chunkSize`, async (_t) => { + const buf = new Uint8Array([1, 2, 3, 4, 5]).buffer; + const out = await streamToArray( + createReadableStream(buf, { chunkSize: 2 }), + ); + deepStrictEqual( + out.map((c) => Array.from(c)), + [[1, 2], [3, 4], [5]], + ); + }); +} + +// createReadableStream() push queue guard: the `chunk !== null` half means +// pushing null (EOF) is always allowed even past the limit. +if (variant === "node") { + test(`${variant}: createReadableStream allows null push even at queue limit`, async (_t) => { + const stream = createReadableStream(undefined, { highWaterMark: 2 }); + stream.push("a"); + stream.push("b"); + // At the limit; pushing null (EOF) must NOT throw (kills `chunk !== null` + // -> `true`, which would throw on the null push). + stream.push(null); + const out = await streamToArray(stream); + deepStrictEqual(out, ["a", "b"]); + }); + + test(`${variant}: createReadableStream allows pushing up to the limit`, async (_t) => { + const stream = createReadableStream(undefined, { highWaterMark: 3 }); + stream.push("a"); + stream.push("b"); + stream.push("c"); + stream.push(null); + const out = await streamToArray(stream); + deepStrictEqual(out, ["a", "b", "c"]); + }); +} + +// --- createPassThroughStream default passThrough is identity (kills the +// ArrowFunction mutant `() => undefined`). With no transform fn, chunks must +// flow through unchanged. --- +test(`${variant}: createPassThroughStream default identity passes chunks through`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream(["a", "b"]), createPassThroughStream()]), + ); + deepStrictEqual(out, ["a", "b"]); +}); + +// --- The thenable check must be a strict `typeof result.then === "function"`. +// A handler that returns a non-null, non-thenable value (e.g. a string) must NOT +// be treated as a promise: with `&& true` (or `!== "function"`, or `=== ""`) +// the code would call `result.then(...)` on a string and throw. Returning a +// plain value must pass cleanly. --- +test(`${variant}: createPassThroughStream tolerates a non-thenable return value`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a", "b"]), + createPassThroughStream((c) => `sync:${c}`), + ]), + ); + deepStrictEqual(out, ["a", "b"]); +}); + +test(`${variant}: createTransformStream tolerates a non-thenable return value`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a", "b"]), + createTransformStream((c, enqueue) => { + enqueue(c); + return "sync-return"; + }), + ]), + ); + deepStrictEqual(out, ["a", "b"]); +}); + +test(`${variant}: createWritableStream tolerates a non-thenable write return`, async (_t) => { + const seen = []; + await pipeline([ + createReadableStream(["a", "b"]), + createWritableStream((c) => { + seen.push(c); + return `sync:${c}`; + }), + ]); + deepStrictEqual(seen, ["a", "b"]); +}); + +test(`${variant}: createPassThroughStream flush tolerates a non-thenable return`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a"]), + createPassThroughStream( + (c) => c, + () => "flush-sync", + ), + ]), + ); + deepStrictEqual(out, ["a"]); +}); + +test(`${variant}: createTransformStream flush tolerates a non-thenable return`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(["a"]), + createTransformStream( + (c, enqueue) => enqueue(c), + () => "flush-sync", + ), + ]), + ); + deepStrictEqual(out, ["a"]); +}); + +test(`${variant}: createWritableStream final tolerates a non-thenable return`, async (_t) => { + const seen = []; + await pipeline([ + createReadableStream(["a"]), + createWritableStream( + (c) => seen.push(c), + () => "final-sync", + ), + ]); + deepStrictEqual(seen, ["a"]); +}); + +// --- async vs sync result detection in create*Stream. A function returning a +// thenable must be awaited BEFORE the chunk is pushed / callback runs. Ordering +// proves the promise branch (kills `=== "function"` -> `!== "function"` / +// `true` / `false` / "" in the thenable checks). --- +test(`${variant}: createPassThroughStream awaits a returned promise before continuing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createPassThroughStream(async () => { + order.push("transform-start"); + await timeout(20); + order.push("transform-end"); + }), + createWritableStream((c) => { + order.push(`write-${c}`); + }), + ]; + await pipeline(streams); + deepStrictEqual(order, ["transform-start", "transform-end", "write-a"]); +}); + +test(`${variant}: createTransformStream awaits a returned promise before continuing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createTransformStream(async (chunk, enqueue) => { + order.push("t-start"); + await timeout(20); + order.push("t-end"); + enqueue(chunk); + }), + createWritableStream((c) => order.push(`w-${c}`)), + ]; + await pipeline(streams); + deepStrictEqual(order, ["t-start", "t-end", "w-a"]); +}); + +test(`${variant}: createWritableStream awaits a returned promise before final`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createWritableStream( + async () => { + order.push("write-start"); + await timeout(20); + order.push("write-end"); + }, + () => { + order.push("final"); + }, + ), + ]; + await pipeline(streams); + deepStrictEqual(order, ["write-start", "write-end", "final"]); +}); + +test(`${variant}: createPassThroughStream awaits an async flush before finishing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createPassThroughStream( + () => order.push("pass"), + async () => { + order.push("flush-start"); + await timeout(20); + order.push("flush-end"); + }, + ), + createWritableStream(), + ]; + await pipeline(streams); + deepStrictEqual(order, ["pass", "flush-start", "flush-end"]); +}); + +test(`${variant}: createTransformStream awaits an async flush before finishing`, async (_t) => { + const order = []; + const streams = [ + createReadableStream(["a"]), + createTransformStream( + (c, enqueue) => { + order.push("t"); + enqueue(c); + }, + async () => { + order.push("flush-start"); + await timeout(20); + order.push("flush-end"); + }, + ), + createWritableStream(), + ]; + await pipeline(streams); + deepStrictEqual(order, ["t", "flush-start", "flush-end"]); +}); + +// --- deepClone / deepEqual preserve the original error as `cause` (kills +// ObjectLiteral -> {} which drops the cause). --- +test(`${variant}: deepClone attaches the original error as cause`, (_t) => { + try { + deepClone({ fn: () => {} }); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("clone chunk")); + ok(e.cause instanceof Error, "expected a cause error"); + } +}); + +test(`${variant}: deepEqual attaches the original error as cause`, (_t) => { + const a = {}; + a.self = a; + try { + deepEqual(a, a); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("stringify chunk")); + ok(e.cause instanceof Error, "expected a cause error"); + } +}); + +// --- shallowEqual: the `b == null` half of the guard. shallowEqual(obj, null) +// must be false; if `|| b == null` were dropped it would call Object.keys(null) +// and throw instead of returning false. --- +test(`${variant}: shallowEqual returns false when only b is null`, (_t) => { + strictEqual(shallowEqual({ a: 1 }, null), false); + strictEqual(shallowEqual({ a: 1 }, undefined), false); +}); + +// --- timeout abort: exact error shape (kills cause/code ObjectLiteral and +// StringLiteral mutants) for BOTH the already-aborted and abort-during paths. --- +test(`${variant}: timeout already-aborted rejects with AbortError cause code`, async (_t) => { + const controller = new AbortController(); + controller.abort(); + try { + await timeout(1000, { signal: controller.signal }); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "Aborted"); + deepStrictEqual(e.cause, { code: "AbortError" }); + strictEqual(e.cause.code, "AbortError"); + } +}); + +test(`${variant}: timeout abort-during rejects with AbortError cause code`, async (_t) => { + const controller = new AbortController(); + const promise = timeout(60_000, { signal: controller.signal }); + controller.abort(); + try { + await promise; + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "Aborted"); + deepStrictEqual(e.cause, { code: "AbortError" }); + strictEqual(e.cause.code, "AbortError"); + } +}); + +// timeout abort handler `settled` guard: after an abort, the later timer firing +// must NOT resolve the already-rejected promise (double-settle is a no-op). +test(`${variant}: timeout abort wins the race; later timer does not resolve`, async (_t) => { + const controller = new AbortController(); + const promise = timeout(30, { signal: controller.signal }); + controller.abort(); + let resolved = false; + let rejected = false; + promise.then( + () => { + resolved = true; + }, + () => { + rejected = true; + }, + ); + // Wait past the original 30ms timer to ensure its callback, if it ran, + // cannot flip an already-settled promise to resolved. + await timeout(60); + strictEqual(rejected, true); + strictEqual(resolved, false); +}); + +test(`${variant}: timeout removes its abort listener after resolving normally`, async (_t) => { + const controller = new AbortController(); + const { signal } = controller; + let removedAbort = 0; + const realRemove = signal.removeEventListener.bind(signal); + signal.removeEventListener = (type, ...rest) => { + if (type === "abort") removedAbort += 1; + return realRemove(type, ...rest); + }; + await timeout(10, { signal }); + // On normal resolution the listener registered for "abort" must be removed + // (kills the `if (signal) signal.removeEventListener("abort", ...)` and the + // "" string mutant in the resolve path). + strictEqual(removedAbort, 1); +}); + +test(`${variant}: timeout removes its abort listener on the abort path`, async (_t) => { + const controller = new AbortController(); + const { signal } = controller; + let removedAbort = 0; + const realRemove = signal.removeEventListener.bind(signal); + signal.removeEventListener = (type, ...rest) => { + if (type === "abort") removedAbort += 1; + return realRemove(type, ...rest); + }; + const promise = timeout(60_000, { signal }); + controller.abort(); + await promise.catch(() => {}); + // The abort handler must remove its own "abort" listener (kills the + // removeEventListener("abort", ...) -> "" string mutant on the abort path). + strictEqual(removedAbort, 1); +}); + +// --- backpressureGauge: duration arithmetic is a SUBTRACTION (Date.now() - +// start), so durations are small/non-negative, not gigantic sums (kills `+`). --- +test(`${variant}: backpressureGauge pause/resume duration is a small subtraction`, async (_t) => { + const transform = createTransformStream(); + const metrics = backpressureGauge({ transform }); + transform.emit("pause"); + await timeout(15); + transform.emit("resume"); + const { duration } = metrics.transform.timeline[0]; + ok(duration >= 0, `duration should be >=0, got ${duration}`); + // A summed (Date.now()+timestamp) value would be ~2*Date.now() (>1e12). + ok(duration < 1e6, `duration should be small, got ${duration}`); +}); + +// total recorded via 'finish'/'close' for writable sinks, AND the +// double-record guard means total.timestamp is set exactly once. +test(`${variant}: backpressureGauge records writable total exactly once`, async (_t) => { + const writable = createWritableStream(); + const metrics = backpressureGauge({ writable }); + // Fire all three terminal events; guard must record total exactly once. + writable.emit("finish"); + const first = metrics.writable.total.timestamp; + const firstDuration = metrics.writable.total.duration; + strictEqual(typeof first, "number"); + await timeout(30); + writable.emit("close"); + writable.emit("end"); + // Both timestamp AND duration must be unchanged by the later events. The + // recorded timestamp is always startTimestamp (so it can't move), but + // duration is Date.now()-start, so a re-record after a 30ms wait would + // bump duration. The `!= null` guard must prevent that (kills it -> false). + strictEqual(metrics.writable.total.timestamp, first); + strictEqual(metrics.writable.total.duration, firstDuration); + ok(metrics.writable.total.duration >= 0); + ok(metrics.writable.total.duration < 1e6); +}); + +// 'finish' and 'close' events are both wired (kills on("") string mutants): +// a writable that emits only 'finish' (no 'end') must still get a total. +test(`${variant}: backpressureGauge records total on the finish event`, async (_t) => { + const writable = createWritableStream(); + const metrics = backpressureGauge({ writable }); + writable.emit("finish"); + strictEqual(typeof metrics.writable.total.timestamp, "number"); +}); + +test(`${variant}: backpressureGauge records total on the close event`, async (_t) => { + const writable = createWritableStream(); + const metrics = backpressureGauge({ writable }); + writable.emit("close"); + strictEqual(typeof metrics.writable.total.timestamp, "number"); +}); + +// --- pipeline derives objectMode from a trailing readable's state and pushes a +// sink built from a (non-empty) streamOptions object. With the readable-last +// case, object chunks must drain without a non-buffer-chunk error. (covers the +// isReadable branch and the streamOptions object literal.) --- +test(`${variant}: pipeline drains an object-mode readable-last stream`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1 }, { b: 2 }]), + createTransformStream((c, enqueue) => enqueue(c)), + ]; + const out = await withTimeout(pipeline(streams)); + deepStrictEqual(out, {}); +}); + +// --- pipeline passes the spread streamOptions object (not `{}`) to +// pipelinePromise for a readable-last pipeline. A signal supplied in +// streamOptions and aborted mid-flight must abort the pipeline. The +// ObjectLiteral -> `{}` mutant drops the signal so the pipeline runs to +// completion and resolves instead of rejecting. --- +if (variant === "node") { + test(`${variant}: pipeline forwards a streamOptions signal to the readable-last sink`, async (_t) => { + // A slow trailing readable so the abort lands while the pipeline runs. + let emitted = 0; + const slowReadable = new Readable({ + objectMode: true, + read() { + setTimeout(() => { + this.push(emitted < 10 ? String(emitted++) : null); + }, 20); + }, + }); + const controller = new AbortController(); + const pending = pipeline([slowReadable], { signal: controller.signal }); + setTimeout(() => controller.abort(), 30); + try { + await withTimeout(pending); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.code, "ABORT_ERR"); + } + }); +} + +// --- size accumulation `chunk?.length ?? chunk?.byteLength ?? N` is computed +// for every chunk. An `undefined` chunk leaves both optional chains nullish so +// the `?? N` fallback applies. Removing either `?.` (OptionalChaining mutant) +// makes `undefined.length` / `undefined.byteLength` throw, so a stream carrying +// an `undefined` chunk must still drain cleanly. Covers .on and async paths of +// every collector. --- +if (variant === "node") { + test(`${variant}: streamToArray tolerates an undefined chunk (.on + async paths)`, async (_t) => { + const onOut = await streamToArray(emitterStream([undefined, "a"])); + deepStrictEqual(onOut, [undefined, "a"]); + const asyncOut = await streamToArray(asyncGen([undefined, "a"])); + deepStrictEqual(asyncOut, [undefined, "a"]); + }); + + test(`${variant}: streamToString tolerates an undefined chunk (.on + async paths)`, async (_t) => { + const onOut = await streamToString(emitterStream([undefined, "a"])); + strictEqual(onOut, "a"); + const asyncOut = await streamToString(asyncGen([undefined, "a"])); + strictEqual(asyncOut, "a"); + }); + + test(`${variant}: streamToObject tolerates an undefined chunk (.on + async paths)`, async (_t) => { + const onOut = await streamToObject(emitterStream([undefined, { a: 1 }])); + deepStrictEqual(onOut, { a: 1 }); + const asyncOut = await streamToObject(asyncGen([undefined, { a: 1 }])); + deepStrictEqual(asyncOut, { a: 1 }); + }); +} + +// --- createReadableStream input dispatch. An iterable WITHOUT a `.map` method +// (a Set) must reach the `Readable.from(input)` branch. The Array.isArray -> +// `true` mutant would call `set.map(...)`, which throws because Set has no map. --- +test(`${variant}: createReadableStream streams a non-array iterable (Set) input`, async (_t) => { + const input = new Set(["a", "b", "c"]); + const streams = [createReadableStream(input)]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, ["a", "b", "c"]); +}); + +// --- createReadableStreamFromString / ...FromArrayBuffer read chunkSize via +// `streamOptions?.chunkSize`. Calling them directly with a `null` options object +// must fall back to the default chunkSize; the OptionalChaining mutant +// (`streamOptions.chunkSize`) would throw on `null.chunkSize`. --- +test(`${variant}: createReadableStreamFromString tolerates null streamOptions`, async (_t) => { + const mod = await import("@datastream/core"); + const stream = mod.createReadableStreamFromString("abcdef", null); + const output = await streamToString(stream); + strictEqual(output, "abcdef"); +}); + +test(`${variant}: createReadableStreamFromArrayBuffer tolerates null streamOptions`, async (_t) => { + const mod = await import("@datastream/core"); + const input = new Uint8Array([1, 2, 3, 4, 5]).buffer; + const stream = mod.createReadableStreamFromArrayBuffer(input, null); + const output = await streamToArray(stream); + deepStrictEqual(Array.from(output[0]), [1, 2, 3, 4, 5]); +}); + +// --- string iterator loops `while (position < length)`. With a chunkSize that +// divides the input length exactly, the `<=` (EqualityOperator) mutant runs one +// extra iteration and yields a trailing empty-string chunk. Pin the exact chunk +// list so a phantom "" chunk fails the assertion. --- +test(`${variant}: createReadableStreamFromString stops exactly at the end (no trailing empty chunk)`, async (_t) => { + const mod = await import("@datastream/core"); + // length 8, chunkSize 4 -> exactly 2 chunks, no remainder. + const stream = mod.createReadableStreamFromString("abcdefgh", { + chunkSize: 4, + }); + const output = await streamToArray(stream); + deepStrictEqual(output, ["abcd", "efgh"]); +}); + +// --- ArrayBuffer iterator loops `while (position < length)`. With a chunkSize +// dividing byteLength exactly, the `<=` mutant runs one extra iteration and +// yields a trailing empty (zero-length) chunk. Pin exactly 2 chunks. --- +test(`${variant}: createReadableStreamFromArrayBuffer stops exactly at the end (no trailing empty chunk)`, async (_t) => { + const mod = await import("@datastream/core"); + // 8 bytes, chunkSize 4 -> exactly 2 chunks of 4 bytes, no remainder. + const input = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer; + const stream = mod.createReadableStreamFromArrayBuffer(input, { + chunkSize: 4, + }); + const output = await streamToArray(stream); + strictEqual(output.length, 2); + deepStrictEqual(Array.from(output[0]), [1, 2, 3, 4]); + deepStrictEqual(Array.from(output[1]), [5, 6, 7, 8]); +}); + +// --- createPassThroughStream default passThrough is `(chunk) => chunk`. The +// default's return value feeds the thenable detection: when a chunk is itself a +// rejecting thenable, the default returns it, the stream awaits it, and the +// rejection surfaces as a stream error. The ArrowFunction mutant +// (`() => undefined`) returns undefined, skips the await, and the pipeline +// would resolve instead of rejecting. --- +test(`${variant}: createPassThroughStream default forwards the chunk's own thenable (rejection surfaces)`, async (_t) => { + // A thenable object whose `then` rejects. Built via a computed key so the + // linter's no-thenable-literal rule is not tripped; it is intentional here. + const thenKey = "then"; + const rejecting = { + [thenKey](_resolve, reject) { + reject(new Error("thenable-rejected")); + }, + }; + // Push the thenable directly (Readable.from would await/reject it before the + // transform); piping hands the raw thenable to the default passThrough, whose + // returned value (the chunk) is then awaited. + const source = createReadableStream(); + process.nextTick(() => { + source.push(rejecting); + source.push(null); + }); + const streams = [source, createPassThroughStream()]; + try { + await withTimeout(pipeline(streams)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "thenable-rejected"); + } +}); + +// --- createWritableStream final handler awaits a returned thenable. An async +// `final` that REJECTS must propagate as a pipeline error. The thenable-check +// mutants (`if (false)`, `typeof ... === ""`) would take the else branch and +// resolve, swallowing the rejection. --- +test(`${variant}: createWritableStream propagates a rejecting async final`, async (_t) => { + const streams = [ + createReadableStream(["a", "b", "c"]), + createWritableStream( + () => {}, + async () => { + throw new Error("final-rejected"); + }, + ), + ]; + try { + await withTimeout(pipeline(streams)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "final-rejected"); + } +}); + +// =========================================================================== +// *** Stream-leak resilience *** +// Legacy `.pipe()` only tears a chain down on `finish`; when a stream errors +// or a consumer closes early it leaves the upstream source running, producing +// into a dead pipeline (the classic Node.js stream leak). pipejoin must mirror +// what pipeline()/pipelinePromise does internally: destroy EVERY stream on the +// first error so nothing is left running. pipeline() (pipelinePromise) already +// does this; these tests pin both behaviours so neither can regress. +// =========================================================================== + +// pipejoin's teardown is build-agnostic: the node build loads under both the +// node and webstream conditions, so this runs under every variant to keep the +// teardown `settled` re-entry guard and the already-destroyed skip covered in +// both builds (the variant-gated tests below would leave those branches uncovered +// under webstream). +test(`${variant}: pipejoin teardown is idempotent and skips already-destroyed streams`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const failing = createTransformStream(() => { + throw new Error("teardown-failure"); + }); + const sink = createWritableStream(); + // Count how often the already-destroyed erroring stream gets destroy()ed: Node + // tears `failing` down on throw, so teardown's loop must SKIP it (the + // `!stream.destroyed` guard) — it must end at exactly one destroy, not two. + let failingDestroyCalls = 0; + const failingDestroy = failing.destroy.bind(failing); + failing.destroy = (error) => { + failingDestroyCalls++; + return failingDestroy(error); + }; + // Destroying the streams re-emits 'error', re-entering teardown; the `settled` + // guard must short-circuit so onError fires exactly once, not once per stream. + let onErrorCalls = 0; + pipejoin([source, failing, sink], () => { + onErrorCalls++; + }); + await timeout(50); + strictEqual( + onErrorCalls, + 1, + "onError must fire exactly once (settled guard)", + ); + strictEqual( + failingDestroyCalls, + 1, + "already-destroyed stream must not be re-destroyed", + ); + strictEqual(source.destroyed, true); + strictEqual(failing.destroyed, true); + strictEqual(sink.destroyed, true); +}); + +if (variant === "node") { + // A mid-chain error must destroy the upstream source AND the downstream sink, + // not just the stream that threw. With bare `.pipe()` the source survives and + // keeps producing — the leak this guards against. + test(`${variant}: pipejoin destroys every stream when one errors (no upstream leak)`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const failing = createTransformStream(() => { + throw new Error("mid-failure"); + }); + const sink = createWritableStream(); + // Swallow the error; we are asserting on teardown, not propagation here. + pipejoin([source, failing, sink], () => {}); + await timeout(50); + strictEqual(source.destroyed, true, "source must be destroyed"); + strictEqual(failing.destroyed, true, "failing transform must be destroyed"); + strictEqual(sink.destroyed, true, "downstream sink must be destroyed"); + }); + + // The error must still reach a collector consuming the joined stream, and the + // upstream source must be torn down rather than left producing. + test(`${variant}: pipejoin surfaces the error to a collector and tears the source down`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const failing = createTransformStream((chunk, enqueue) => { + if (chunk === "b") throw new Error("collector-failure"); + enqueue(chunk); + }); + const joined = pipejoin([source, failing], () => {}); + try { + await withTimeout(streamToArray(joined)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "collector-failure"); + } + strictEqual(source.destroyed, true, "source must be destroyed on error"); + }); + + // A consumer that stops reading early (break out of the async iterator) must + // not leave the source running: the iterator protocol destroys the readable, + // and pipejoin's teardown must propagate that to every upstream stream. + test(`${variant}: pipejoin tears the source down when the consumer stops early`, async (_t) => { + const source = createReadableStream(["a", "b", "c", "d", "e"]); + const passThrough = createPassThroughStream((c) => c); + const joined = pipejoin([source, passThrough], () => {}); + for await (const chunk of joined) { + if (chunk === "b") break; // early exit -> iterator destroys `joined` + } + await timeout(50); + strictEqual(joined.destroyed, true, "joined stream must be destroyed"); + strictEqual( + source.destroyed, + true, + "source must be destroyed on early exit", + ); + }); + + // Characterization: pipeline() (pipelinePromise) already destroys the whole + // chain on a mid-stream error. Pin it so the resilient teardown can't regress. + test(`${variant}: pipeline destroys the source when a transform errors`, async (_t) => { + const source = createReadableStream(["a", "b", "c"]); + const streams = [ + source, + createTransformStream(() => { + throw new Error("pipeline-mid-failure"); + }), + ]; + try { + await withTimeout(pipeline(streams)); + throw new Error("Should have thrown"); + } catch (e) { + strictEqual(e.message, "pipeline-mid-failure"); + } + strictEqual(source.destroyed, true, "pipeline must destroy the source"); + }); +} diff --git a/packages/core/index.web.js b/packages/core/index.web.js index 16b5887..dd6edf9 100644 --- a/packages/core/index.web.js +++ b/packages/core/index.web.js @@ -3,9 +3,15 @@ /* global ReadableStream, TransformStream, WritableStream */ export const pipeline = async (streams, streamOptions = {}) => { + // Work on a copy so appending the terminal writable doesn't mutate the + // caller's array. + streams = [...streams]; // Ensure stream ends with only writable const lastStream = streams[streams.length - 1]; if (isReadable(lastStream)) { + // Web Streams have no objectMode flag, so (unlike the Node build, which + // derives objectMode from the source) the auto-appended terminal sink + // intentionally just forwards the caller's streamOptions. streams.push(createWritableStream(() => {}, streamOptions)); } @@ -76,6 +82,10 @@ export const streamToObject = async (stream, { maxBufferSize } = {}) => { export const streamToString = async (stream, { maxBufferSize } = {}) => { const chunks = []; let size = 0; + // A streaming TextDecoder so multibyte sequences split across byte chunks + // decode correctly. Mirrors Node's Buffer.concat(...).toString() semantics: + // byte chunks are decoded as UTF-8, anything else is String()-ified. + let decoder; for await (const chunk of stream) { if (maxBufferSize != null) { size += chunk?.length ?? chunk?.byteLength ?? 0; @@ -85,16 +95,58 @@ export const streamToString = async (stream, { maxBufferSize } = {}) => { ); } } - chunks.push(chunk); + if (ArrayBuffer.isView(chunk) || chunk instanceof ArrayBuffer) { + // Without { stream: true } a typed array of raw bytes would be + // coerced by Array.join into comma-separated decimal byte codes. + decoder ??= new TextDecoder(); + chunks.push(decoder.decode(chunk, { stream: true })); + } else { + chunks.push(`${chunk}`); + } } + // Flush any bytes buffered by the streaming decoder. + if (decoder) chunks.push(decoder.decode()); return chunks.join(""); }; +// Browser parity for the Node streamToBuffer. There is no Buffer in the web +// runtime, so this returns a concatenated Uint8Array (Node's Buffer is itself +// a Uint8Array, so byte consumers behave identically across builds). +export const streamToBuffer = async (stream, { maxBufferSize } = {}) => { + const value = []; + let size = 0; + for await (const chunk of stream) { + const buf = + chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(chunk); + if (maxBufferSize != null) { + size += buf.byteLength; + if (size > maxBufferSize) { + throw new Error( + `streamToBuffer buffer exceeds maxBufferSize (${maxBufferSize})`, + ); + } + } + value.push(buf); + } + const total = value.reduce((n, b) => n + b.byteLength, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const b of value) { + out.set(b, offset); + offset += b.byteLength; + } + return out; +}; + export const isReadable = (stream) => { + // Short-circuit non-objects so the `.readable` access can't throw on + // null/undefined/primitives (Node's instanceof check safely returns false). + if (stream == null || typeof stream !== "object") return false; return stream instanceof ReadableStream || !!stream.readable; }; export const isWritable = (stream) => { + if (stream == null || typeof stream !== "object") return false; return stream instanceof WritableStream || !!stream.writable; }; @@ -125,13 +177,29 @@ export const createReadableStream = (input, streamOptions = {}) => { if (chunkSize <= 0) throw new Error("chunkSize must be a positive number"); const queued = []; const { readableStrategy } = makeOptions(streamOptions); + // Shared drain logic for start() and pull() so both paths treat a queued + // null identically as EOF. Returns false once it has closed the stream. + const drainQueued = (controller) => { + while (queued.length) { + const chunk = queued.shift(); + if (chunk === null) { + controller.close(); + return false; + } + controller.enqueue(chunk); + } + return true; + }; const stream = new ReadableStream( { async start(controller) { - while (queued.length) { - const chunk = queued.shift(); - controller.enqueue(chunk); - } + // Drain pre-start pushes using the SAME null-as-EOF semantics as + // pull(); the WHATWG spec allows an async start(), so a terminating + // null queued before start runs must still close the stream. + if (!drainQueued(controller)) return; + // No input => manual-push mode (mirrors the node build's undefined + // branch): leave the stream open for stream.push()/pull(). + if (input === undefined) return; if (typeof input === "string") { let position = 0; const length = input.length; @@ -146,8 +214,18 @@ export const createReadableStream = (input, streamOptions = {}) => { controller.enqueue(input[i]); } controller.close(); - } else if (typeof input === "object" && input.byteLength) { - const bytes = new Uint8Array(input.buffer ?? input); + } else if ( + input != null && + typeof input === "object" && + input.byteLength + ) { + // Honor the view's byteOffset/byteLength; constructing + // new Uint8Array(input.buffer) over the whole backing buffer would + // leak adjacent heap (pooled Buffers / .subarray() views) and + // diverge from the Node build. + const bytes = ArrayBuffer.isView(input) + ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) + : new Uint8Array(input); let position = 0; const length = bytes.byteLength; while (position < length) { @@ -155,23 +233,36 @@ export const createReadableStream = (input, streamOptions = {}) => { position += chunkSize; } controller.close(); - } else if (["function", "object"].includes(typeof input)) { + } else if ( + input != null && + ["function", "object"].includes(typeof input) && + typeof input[Symbol.asyncIterator] === "function" + ) { for await (const chunk of input) { controller.enqueue(chunk); } controller.close(); - } - }, - pull(controller) { - while (queued.length) { - const chunk = queued.shift(); - if (chunk === null) { - controller.close(); - } else { + } else if ( + input != null && + typeof input === "object" && + typeof input[Symbol.iterator] === "function" + ) { + for (const chunk of input) { controller.enqueue(chunk); } + controller.close(); + } else { + // number/boolean/symbol/null/non-iterable object: a missing branch + // previously left the stream open forever (a silent hang). Error + // promptly to match Node's immediate throw. + controller.error( + new TypeError("createReadableStream: unsupported input type"), + ); } }, + pull(controller) { + drainQueued(controller); + }, }, readableStrategy, ); @@ -194,27 +285,50 @@ export const createPassThroughStream = (passThrough, flush, streamOptions) => { } const { signal } = streamOptions ?? {}; const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + // Track the abort listener so we can remove it once the stream settles; + // otherwise a shared signal accumulates a listener per constructed stream. + // cleanup() is idempotent and is invoked from EVERY terminal path (clean + // flush, downstream cancel, and a thrown transform/flush callback), not just + // the clean flush — otherwise an errored/cancelled stream leaks its listener. + let onAbort; + const cleanup = () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; return new TransformStream( { start(controller) { if (signal) { - signal.addEventListener("abort", () => { + onAbort = () => controller.error( signal.reason ?? new DOMException("Aborted", "AbortError"), ); - }); + signal.addEventListener("abort", onAbort, { once: true }); } }, async transform(chunk, controller) { - await passThrough(chunk); + try { + await passThrough(chunk); + } catch (e) { + cleanup(); + throw e; + } controller.enqueue(chunk); }, async flush(controller) { + cleanup(); if (flush) { await flush(); } controller.terminate(); }, + // Called when the readable side is cancelled or the writable side is + // aborted/errored (e.g. a downstream sink errors). + cancel() { + cleanup(); + }, }, writableStrategy, readableStrategy, @@ -229,24 +343,39 @@ export const createTransformStream = (transform, flush, streamOptions) => { } const { signal } = streamOptions ?? {}; const { writableStrategy, readableStrategy } = makeOptions(streamOptions); + // See createPassThroughStream for why cleanup() is wired into every terminal + // path rather than only the clean flush. + let onAbort; + const cleanup = () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; return new TransformStream( { start(controller) { if (signal) { - signal.addEventListener("abort", () => { + onAbort = () => controller.error( signal.reason ?? new DOMException("Aborted", "AbortError"), ); - }); + signal.addEventListener("abort", onAbort, { once: true }); } }, async transform(chunk, controller) { const enqueue = (chunk) => { controller.enqueue(chunk); }; - await transform(chunk, enqueue); + try { + await transform(chunk, enqueue); + } catch (e) { + cleanup(); + throw e; + } }, async flush(controller) { + cleanup(); if (flush) { const enqueue = (chunk) => { controller.enqueue(chunk); @@ -255,6 +384,9 @@ export const createTransformStream = (transform, flush, streamOptions) => { } controller.terminate(); }, + cancel() { + cleanup(); + }, }, writableStrategy, readableStrategy, @@ -269,25 +401,45 @@ export const createWritableStream = (write, close, streamOptions) => { } const { signal } = streamOptions ?? {}; const { writableStrategy } = makeOptions(streamOptions); + // See createPassThroughStream for why cleanup() is wired into every terminal + // path (clean close, abort, and a thrown write/close callback). + let onAbort; + const cleanup = () => { + if (onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + }; return new WritableStream( { start(controller) { if (signal) { - signal.addEventListener("abort", () => { + onAbort = () => controller.error( signal.reason ?? new DOMException("Aborted", "AbortError"), ); - }); + signal.addEventListener("abort", onAbort, { once: true }); } }, async write(chunk) { - await write(chunk); + try { + await write(chunk); + } catch (e) { + cleanup(); + throw e; + } }, async close() { + cleanup(); if (close) { await close(); } }, + // Called when the stream is aborted (signal fires or a downstream error + // propagates), where close() would never run. + abort() { + cleanup(); + }, }, writableStrategy, ); diff --git a/packages/core/package.json b/packages/core/package.json index 7c9e3dc..4bdd7f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/core", - "version": "0.5.0", + "version": "0.6.1", "description": "Stream creation utilities and pipeline functions for Web Streams API and Node.js streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -61,6 +62,6 @@ "homepage": "https://datastream.js.org", "dependencies": {}, "devDependencies": { - "@datastream/object": "0.5.0" + "@datastream/object": "0.6.1" } } diff --git a/packages/csv/README.md b/packages/csv/README.md index 84c4670..700fd93 100644 --- a/packages/csv/README.md +++ b/packages/csv/README.md @@ -1,6 +1,6 @@CSV parsing and formatting streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/csv/index.js b/packages/csv/index.js index 831c843..132cf28 100644 --- a/packages/csv/index.js +++ b/packages/csv/index.js @@ -5,10 +5,7 @@ import { createTransformStream, resolveLazy, } from "@datastream/core"; -import { - objectFromEntriesStream, - objectToEntriesStream, -} from "@datastream/object"; +import { objectToEntriesStream } from "@datastream/object"; const comma = ","; const quote = "'"; @@ -34,9 +31,79 @@ const stripBOM = (str) => { return str.charCodeAt(0) === 0xfeff ? str.slice(1) : str; }; +// True when the quote at `idx` is escaped — i.e. preceded by an ODD run of +// escapeChar (scanning no further back than `lowerBound`). A "not found" index +// of -1 yields false (the lookback inspects nothing), so callers can use this +// directly as a loop condition without a separate -1 check. +const quoteIsEscaped = (text, idx, lowerBound, escapeCode) => { + let escaped = false; + let k = idx - 1; + while (k >= lowerBound && text.charCodeAt(k) === escapeCode) { + escaped = !escaped; + k--; + } + return escaped; +}; + +// Quote/escape-aware scan for the end of the first record (row). Returns the +// index of the newline that terminates row 0, or -1 if no complete row is +// present. Newlines inside quoted fields are skipped so a quoted newline does +// not split the row. Mirrors the field-start quoting rules of the parser. +const findRowEnd = ( + text, + delimiterChar, + newlineChar, + quoteChar, + escapeChar, +) => { + const quoteCode = quoteChar.charCodeAt(0); + const escapeCode = escapeChar.charCodeAt(0); + const delimiterLength = delimiterChar.length; + let pos = 0; + let nextNl = text.indexOf(newlineChar, 0); + for (;;) { + // `pos` is always a field start here (it advances only past a closing quote + // or a delimiter), so a quote at `pos` always opens a quoted field. + if (text.charCodeAt(pos) === quoteCode) { + // Quoted field: find the matching closing quote with indexOf. A quote is + // escaped (does not close the field) only when the run of escapeChar + // immediately before it is odd. When escapeChar === quoteChar this run + // counts the doubled "" quotes, so one algorithm covers both conventions. + // Only the parity of quotes matters for locating the row-terminating + // newline, so this matches the parser's field-close position. + const contentStart = pos + 1; + let closeQ = text.indexOf(quoteChar, contentStart); + // Skip escaped quotes; a "not found" (-1) is treated as not-escaped and + // ends the loop. + while (quoteIsEscaped(text, closeQ, contentStart, escapeCode)) { + closeQ = text.indexOf(quoteChar, closeQ + 1); + } + // Unterminated quote → no complete row in this buffer. + if (closeQ === -1) return -1; + pos = closeQ + 1; + continue; + } + if (nextNl !== -1 && nextNl < pos) { + nextNl = text.indexOf(newlineChar, pos); + } + const nextDelim = text.indexOf(delimiterChar, pos); + // Advance past a delimiter that falls at or before the row's newline (a + // delimiter that is a prefix of the newline wins the tie). When there is no + // newline, nextNl is -1 and `nextDelim <= -1` is false, so the loop returns + // -1 (an incomplete row). Otherwise the newline ends row 0. + if (nextDelim !== -1 && nextDelim <= nextNl) { + pos = nextDelim + delimiterLength; + continue; + } + return nextNl; + } +}; + export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { const { - chunkSize = 1024, // 1KB + // chunkSize is accepted for compatibility; detect() already waits for a + // complete first line, so no byte threshold is needed. + chunkSize: _chunkSize, resultKey, } = options; @@ -66,9 +133,41 @@ export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { (delimiter) => headerString.indexOf(delimiter) > -1, ) ?? defaultDelimiterChar; + // A char is only the quote char when it actually BRACKETS a field: it + // opens at a field-start (text start, or right after a delimiter or a + // newline) AND a matching quote closes the field right before the next + // delimiter/newline/end. A bare apostrophe/quote inside ordinary data + // (e.g. "'twas the night", which opens but never closes a field) must + // NOT be mistaken for the quote char. + const delimiterChar = value.delimiterChar; + const cr = carageReturn.charCodeAt(0); + const lf = lineFeed.charCodeAt(0); + const isFieldStart = (i) => + i === 0 || + text.startsWith(delimiterChar, i - delimiterChar.length) || + text.charCodeAt(i - 1) === cr || + text.charCodeAt(i - 1) === lf; + const isFieldEnd = (i) => + i >= text.length || + text.startsWith(delimiterChar, i) || + text.charCodeAt(i) === cr || + text.charCodeAt(i) === lf; value.quoteChar = - detectQuoteChars.find((delimiter) => text.indexOf(delimiter) > -1) ?? - defaultQuoteChar; + detectQuoteChars.find((candidate) => { + let i = text.indexOf(candidate); + while (i > -1) { + if (isFieldStart(i)) { + // Look for a closing quote that ends the field. + let close = text.indexOf(candidate, i + 1); + while (close > -1) { + if (isFieldEnd(close + 1)) return true; + close = text.indexOf(candidate, close + 1); + } + } + i = text.indexOf(candidate, i + 1); + } + return false; + }) ?? defaultQuoteChar; value.escapeChar = value.quoteChar; return true; }; @@ -79,18 +178,21 @@ export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { return; } buffer += chunk; - if (buffer.length >= chunkSize && detect(buffer)) { + // detect() returns false until the buffer holds a complete first line, so + // it can be attempted on every chunk without a size threshold. + if (detect(buffer)) { detected = true; enqueue(buffer); - buffer = ""; + // `buffer` is not read again once detected is set. } }; const flush = (enqueue) => { if (!detected && buffer.length > 0) { + // Detect from whatever was buffered (may be a partial line) and emit it. detect(buffer); enqueue(buffer); - buffer = ""; + // End of stream; `buffer` is not read again. } }; @@ -101,7 +203,9 @@ export const csvDetectDelimitersStream = (options = {}, streamOptions = {}) => { export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { let { - chunkSize = 1024, // 1KB + // chunkSize is accepted for compatibility; the header is processed as soon + // as a complete first row is buffered. + chunkSize: _chunkSize, parser, delimiterChar, newlineChar, @@ -110,45 +214,39 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { resultKey, } = options; - const value = { - header: [], - }; + // `header` is always assigned by processBuffer (which runs at the latest on + // flush) before the stream's result() is read. + const value = {}; let buffer = ""; let headerDetected = false; - const processBuffer = (enqueue) => { + const resolveOptions = () => { delimiterChar = resolveLazy(delimiterChar) ?? defaultDelimiterChar; newlineChar = resolveLazy(newlineChar) ?? defaultNewlineChar; quoteChar = resolveLazy(quoteChar) ?? defaultQuoteChar; escapeChar = resolveLazy(escapeChar) ?? quoteChar; + }; + + // Returns the offset of the row-0 terminator within the (BOM-stripped) buffer, + // or -1 if a complete header row is not yet present. + const headerRowEnd = () => + findRowEnd( + stripBOM(buffer), + delimiterChar, + newlineChar, + quoteChar, + escapeChar, + ); + const processBuffer = (enqueue, headerEndOfRow) => { const text = stripBOM(buffer); - buffer = ""; + // `buffer` is not read again once headerDetected is set, so it is left as-is. headerDetected = true; - const headerEndOfRow = text.indexOf(newlineChar); - if (headerEndOfRow === -1) { - // Entire input is header, no data rows - const parserFn = parser ?? csvQuotedParser; - const result = parserFn( - text, - { - delimiterChar, - newlineChar, - quoteChar, - escapeChar, - numCols: 0, - idx: 0, - }, - true, - ); - value.header = result.rows[0] ?? []; - return; - } - - const headerChunk = text.slice(0, headerEndOfRow); const parserFn = parser ?? csvQuotedParser; + const headerChunk = + headerEndOfRow === -1 ? text : text.slice(0, headerEndOfRow); const result = parserFn( headerChunk, { @@ -163,6 +261,11 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { ); value.header = result.rows[0] ?? []; + if (headerEndOfRow === -1) { + // Entire input is header, no data rows + return; + } + const rest = text.slice(headerEndOfRow + newlineChar.length); if (rest.length > 0) { enqueue(rest); @@ -175,14 +278,21 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { return; } buffer += chunk; - if (buffer.length >= chunkSize) { - processBuffer(enqueue); + resolveOptions(); + // Process as soon as a complete header row is buffered (a quoted newline in + // the header does not count); otherwise keep buffering. + const headerEndOfRow = headerRowEnd(); + if (headerEndOfRow !== -1) { + processBuffer(enqueue, headerEndOfRow); } }; const flush = (enqueue) => { - if (!headerDetected && buffer.length > 0) { - processBuffer(enqueue); + // Whatever remains (possibly a partial header row, or nothing) is finalized + // here. On empty input this yields an empty header and emits nothing. + if (!headerDetected) { + resolveOptions(); + processBuffer(enqueue, headerRowEnd()); } }; @@ -195,19 +305,35 @@ export const csvDetectHeaderStream = (options = {}, streamOptions = {}) => { // Both return { rows: string[][], tail: string, numCols: number, idx: number, errors?: {} } // Options can include pre-computed char codes (from csvSteamifyParser) or raw config strings. +// Inverse of csvFormatStream's custom-escape encoding (escapeChar !== quoteChar): +// the formatter escapes escapeChar -> escapeChar+escapeChar and quoteChar -> +// escapeChar+quoteChar. Reverse both in a single left-to-right pass so an +// escapeChar consumes the following char literally (handling escaped escapes +// and escaped quotes together), keeping format/parse a faithful round-trip. +const unescapeCustom = (text, escapeChar) => { + let out = ""; + let start = 0; + let i = text.indexOf(escapeChar, start); + // Each escapeChar consumes the following character literally. A trailing + // escapeChar with no following character (i + 1 === length) is kept as-is. + while (i !== -1 && i + 1 < text.length) { + out += text.substring(start, i) + text[i + 1]; + start = i + 2; + i = text.indexOf(escapeChar, start); + } + return out + text.substring(start); +}; + // Internal hot-path parser. Writes results directly to ctx and calls enqueue(fields) per row. // ctx must have all pre-computed char codes + numCols, idx, tail, errors fields. const csvParseInline = (text, ctx, isFlushing, enqueue) => { - const delimiterCharCode = ctx.delimiterCharCode; const delimiterChar = ctx.delimiterChar; const delimiterCharLength = ctx.delimiterCharLength; - const delimiterCharSingle = ctx.delimiterCharSingle; - const newlineCharCode = ctx.newlineCharCode; - const newlineCharSingle = ctx.newlineCharSingle; const newlineChar = ctx.newlineChar; const newlineCharLength = ctx.newlineCharLength; const quoteCharCode = ctx.quoteCharCode; const quoteChar = ctx.quoteChar; + const escapeChar = ctx.escapeChar; const escapeCharCode = ctx.escapeCharCode; const escapeIsQuote = ctx.escapeIsQuote; const escapedQuote = ctx.escapedQuote; @@ -220,35 +346,36 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { let rowStart = 0; let fieldStart = 0; - let rowTpl = numCols > 0 ? Array(numCols).fill("") : null; - let fields = rowTpl ? rowTpl.slice() : []; - let fi = 0; + // Each row is built by pushing fields in order, so fields.length always + // equals the field count — short rows simply have fewer entries. + let fields = []; let pos = 0; let lastWasDelimiter = false; + let nextNl = text.indexOf(newlineChar, 0); + + // Called at most once per invocation (each unterminated-quote branch returns + // immediately afterwards), so the error map and entry are created fresh here. const trackError = (id, message) => { - if (errors === null) errors = {}; - if (!errors[id]) errors[id] = { id, message, idx: [] }; - errors[id].idx.push(idx); + errors = { [id]: { id, message, idx: [idx] } }; }; - outer: while (pos < len) { - if (text.charCodeAt(pos) === quoteCharCode && pos === fieldStart) { + while (pos < len) { + // The outer loop is only (re)entered at a field start, so a quote here + // always opens a quoted field (mid-field quotes are consumed by the + // unquoted scan below and never reach this check). + if (text.charCodeAt(pos) === quoteCharCode) { // === QUOTED FIELD === lastWasDelimiter = false; pos++; const contentStart = pos; if (escapeIsQuote) { - // Find closing quote using indexOf, skipping escaped "" pairs + // Find the closing quote with indexOf, skipping escaped "" pairs. let closeQ = text.indexOf(quoteChar, pos); - let hasEscapes = false; - while ( - closeQ !== -1 && - closeQ + 1 < len && - text.charCodeAt(closeQ + 1) === quoteCharCode - ) { - hasEscapes = true; + let hadEscaped = false; + while (closeQ !== -1 && text.charCodeAt(closeQ + 1) === quoteCharCode) { + hadEscaped = true; closeQ = text.indexOf(quoteChar, closeQ + 2); } @@ -257,11 +384,10 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { if (isFlushing) { trackError("UnterminatedQuote", "Unterminated quoted field"); const raw = text.substring(contentStart); - fields[fi++] = hasEscapes - ? raw.replaceAll(escapedQuote, quoteChar) - : raw; - if (numCols === 0) numCols = fi; - else if (fi < numCols) fields.length = fi; + fields.push( + hadEscaped ? raw.replaceAll(escapedQuote, quoteChar) : raw, + ); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; } @@ -272,12 +398,10 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { return; } - // Extract field value: single slice + conditional replaceAll - const field = hasEscapes - ? text - .substring(contentStart, closeQ) - .replaceAll(escapedQuote, quoteChar) - : text.substring(contentStart, closeQ); + const slice = text.substring(contentStart, closeQ); + const field = hadEscaped + ? slice.replaceAll(escapedQuote, quoteChar) + : slice; if (field.length > fieldMaxSize) { throw new Error( `CSV field size (${field.length}) exceeds fieldMaxSize (${fieldMaxSize} bytes)`, @@ -285,74 +409,58 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { } pos = closeQ + 1; - // Post-quote dispatch: delimiter, newline, or end-of-input - if (pos >= len) { - fields[fi++] = field; - fieldStart = pos; - break; - } - const nc = text.charCodeAt(pos); - if ( - delimiterCharSingle - ? nc === delimiterCharCode - : text.startsWith(delimiterChar, pos) - ) { - fields[fi++] = field; + // Post-quote dispatch: delimiter, newline, or end-of-input. + // At end-of-input charCodeAt(pos) is NaN, so neither the delimiter + // nor the newline branch matches and the field falls through to the + // "garbage after closing quote" branch, which records the field and + // lets the outer loop terminate — no explicit end guard needed. + if (text.startsWith(delimiterChar, pos)) { + fields.push(field); pos += delimiterCharLength; fieldStart = pos; lastWasDelimiter = true; continue; } - if ( - nc === newlineCharCode && - (newlineCharLength === 1 || - (newlineCharLength === 2 && - pos + 1 < len && - text.charCodeAt(pos + 1) === newlineCharSingle) || - (newlineCharLength > 2 && text.startsWith(newlineChar, pos))) - ) { - fields[fi++] = field; - if (numCols === 0) { - numCols = fi; - rowTpl = Array(numCols).fill(""); - } else if (fi < numCols) fields.length = fi; + if (text.startsWith(newlineChar, pos)) { + fields.push(field); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; - fi = 0; - fields = rowTpl ? rowTpl.slice() : []; + fields = []; pos += newlineCharLength; rowStart = pos; fieldStart = pos; lastWasDelimiter = false; continue; } - // Garbage after closing quote - fields[fi++] = field; + // Garbage after closing quote (also the end-of-input case) + fields.push(field); fieldStart = pos; continue; } - // escapeChar !== quoteChar — use indexOf with lookback + // escapeChar !== quoteChar — find the closing quote with indexOf and a + // run-length lookback. A quote is escaped (does not close the field) only + // when the run of escapeChar immediately before it is odd; an even run + // (e.g. "\\" => an escaped escape) leaves a real closing quote. The + // opening quote (which differs from escapeChar here) terminates the + // lookback, so no explicit lower bound is needed. unescapeCustom reverses + // the escaping and is a no-op on a field with no escapeChar, so it is + // applied unconditionally. let closeQ = text.indexOf(quoteChar, pos); - let hasEscape = false; - while ( - closeQ !== -1 && - closeQ > 0 && - text.charCodeAt(closeQ - 1) === escapeCharCode - ) { - hasEscape = true; + // Skip escaped quotes; a "not found" (-1) is treated as not-escaped and + // ends the loop. + while (quoteIsEscaped(text, closeQ, contentStart, escapeCharCode)) { closeQ = text.indexOf(quoteChar, closeQ + 1); } if (closeQ === -1) { // Unterminated quote - const raw = text.substring(contentStart); - const field = hasEscape ? raw.replaceAll(escapedQuote, quoteChar) : raw; + const field = unescapeCustom(text.substring(contentStart), escapeChar); if (isFlushing) { trackError("UnterminatedQuote", "Unterminated quoted field"); - fields[fi++] = field; - if (numCols === 0) numCols = fi; - else if (fi < numCols) fields.length = fi; + fields.push(field); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; } @@ -363,13 +471,12 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { return; } - // Extract field value: single slice + conditional replaceAll + // Extract field value: single slice + unescape (no-op without escapes) { - const field = hasEscape - ? text - .substring(contentStart, closeQ) - .replaceAll(escapedQuote, quoteChar) - : text.substring(contentStart, closeQ); + const field = unescapeCustom( + text.substring(contentStart, closeQ), + escapeChar, + ); if (field.length > fieldMaxSize) { throw new Error( `CSV field size (${field.length}) exceeds fieldMaxSize (${fieldMaxSize} bytes)`, @@ -377,41 +484,21 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { } pos = closeQ + 1; - // Post-quote dispatch: delimiter, newline, or end-of-input - if (pos >= len) { - fields[fi++] = field; - fieldStart = pos; - break; - } - const nc = text.charCodeAt(pos); - if ( - delimiterCharSingle - ? nc === delimiterCharCode - : text.startsWith(delimiterChar, pos) - ) { - fields[fi++] = field; + // Post-quote dispatch: delimiter, newline, or end-of-input (see the + // escapeIsQuote branch above — the garbage branch also covers EOI). + if (text.startsWith(delimiterChar, pos)) { + fields.push(field); pos += delimiterCharLength; fieldStart = pos; lastWasDelimiter = true; continue; } - if ( - nc === newlineCharCode && - (newlineCharLength === 1 || - (newlineCharLength === 2 && - pos + 1 < len && - text.charCodeAt(pos + 1) === newlineCharSingle) || - (newlineCharLength > 2 && text.startsWith(newlineChar, pos))) - ) { - fields[fi++] = field; - if (numCols === 0) { - numCols = fi; - rowTpl = Array(numCols).fill(""); - } else if (fi < numCols) fields.length = fi; + if (text.startsWith(newlineChar, pos)) { + fields.push(field); + if (numCols === 0) numCols = fields.length; enqueue(fields); idx++; - fi = 0; - fields = rowTpl ? rowTpl.slice() : []; + fields = []; pos += newlineCharLength; rowStart = pos; fieldStart = pos; @@ -419,146 +506,70 @@ const csvParseInline = (text, ctx, isFlushing, enqueue) => { continue; } // Garbage after closing quote - fields[fi++] = field; + fields.push(field); fieldStart = pos; continue; } } - // === UNQUOTED FIELDS — indexOf scan === + // === UNQUOTED FIELD — one field per outer iteration === + // The next quote/newline/delimiter is resolved, the field emitted, and the + // outer loop re-entered so the following field-start is re-dispatched + // (handling a quote that opens the next field). lastWasDelimiter = false; { - let nextNl = text.indexOf(newlineChar, pos); - - // Fast path: no quotes — column-aware indexOf loop - // Finds row boundary first, then processes columns within - // bounds. Fewer allocations than split (no intermediate - // line strings). Handles short/long rows via split fallback. - if ( - fi === 0 && - numCols > 0 && - text.indexOf(quoteChar, fieldStart) === -1 - ) { - const lastFi = numCols - 1; - let rowEnd = nextNl; - while (rowEnd !== -1) { - for (fi = 0; fi < lastFi; fi++) { - const d = text.indexOf(delimiterChar, fieldStart); - if (d === -1 || d > rowEnd) { - // Malformed row: split fallback - fields = text.substring(pos, rowEnd).split(delimiterChar); - fi = numCols; // sentinel: skip lastFi assign - break; - } - fields[fi] = text.substring(fieldStart, d); - fieldStart = d + delimiterCharLength; - } - if (fi === lastFi) { - fields[lastFi] = text.substring(fieldStart, rowEnd); - } - enqueue(fields); - idx++; - fi = 0; - pos = rowEnd + newlineCharLength; - rowStart = pos; - fieldStart = pos; - fields = rowTpl.slice(); - rowEnd = text.indexOf(newlineChar, pos); - } - if (pos >= len) { - break; - } - // Partial row without newline: fall through to regular path - nextNl = -1; + if (nextNl !== -1 && nextNl < pos) { + nextNl = text.indexOf(newlineChar, pos); + } + const nextDelim = text.indexOf(delimiterChar, pos); + + if (nextDelim !== -1 && (nextNl === -1 || nextDelim <= nextNl)) { + // Field terminated by a delimiter (which wins a tie with the newline, + // e.g. when the delimiter is a prefix of the newline) → more fields. + fields.push(text.substring(fieldStart, nextDelim)); + pos = nextDelim + delimiterCharLength; + fieldStart = pos; + lastWasDelimiter = true; + continue; } - // First-row detection: use split to establish numCols - if ( - fi === 0 && - numCols === 0 && - nextNl !== -1 && - text.indexOf(quoteChar, fieldStart) === -1 - ) { - const lineFields = text - .substring(fieldStart, nextNl) - .split(delimiterChar); - numCols = lineFields.length; - rowTpl = Array(numCols).fill(""); - enqueue(lineFields); + if (nextNl !== -1) { + // Field terminated by a newline → end of row. + fields.push(text.substring(fieldStart, nextNl)); + if (numCols === 0) numCols = fields.length; + enqueue(fields); idx++; + fields = []; pos = nextNl + newlineCharLength; rowStart = pos; fieldStart = pos; - fields = rowTpl.slice(); - if (pos >= len) { - break; - } - // Re-enter the fast path via continue outer continue; } - - // Regular indexOf path - while (pos < len) { - const nextDelim = text.indexOf(delimiterChar, pos); - - if (nextNl !== -1 && (nextDelim === -1 || nextNl < nextDelim)) { - fields[fi++] = text.substring(fieldStart, nextNl); - if (numCols === 0) { - numCols = fi; - rowTpl = Array(numCols).fill(""); - } else if (fi < numCols) fields.length = fi; - enqueue(fields); - idx++; - fi = 0; - fields = rowTpl ? rowTpl.slice() : []; - pos = nextNl + newlineCharLength; - rowStart = pos; - fieldStart = pos; - lastWasDelimiter = false; - nextNl = text.indexOf(newlineChar, pos); - if (pos >= len) break; - if (text.charCodeAt(pos) === quoteCharCode) continue outer; - continue; - } - - if (nextDelim !== -1) { - fields[fi++] = text.substring(fieldStart, nextDelim); - pos = nextDelim + delimiterCharLength; - fieldStart = pos; - lastWasDelimiter = true; - if (pos >= len) continue outer; - if (text.charCodeAt(pos) === quoteCharCode) continue outer; - continue; - } - - break; - } } break; } - // Cleanup: partial row at end - if (fieldStart < len || lastWasDelimiter || fi > 0) { - if (isFlushing) { - if (fieldStart < len) { - fields[fi++] = text.substring(fieldStart); - } else if (lastWasDelimiter) { - fields[fi++] = ""; - } - if (fi > 0) { - if (numCols === 0) numCols = fi; - else if (fi < numCols) fields.length = fi; - enqueue(fields); - idx++; - } - } else { - ctx.tail = text.substring(rowStart); - ctx.numCols = numCols; - ctx.idx = idx; - ctx.errors = errors; - return; - } + // Cleanup: a partial row may remain at the end of the chunk. + if (!isFlushing) { + // rowStart marks the start of the unconsumed (incomplete) row; for a fully + // consumed input it equals len and yields an empty tail. + ctx.tail = text.substring(rowStart); + ctx.numCols = numCols; + ctx.idx = idx; + ctx.errors = errors; + return; + } + // Flushing: emit any trailing field or the empty field of a dangling delimiter. + if (fieldStart < len) { + fields.push(text.substring(fieldStart)); + } else if (lastWasDelimiter) { + fields.push(""); + } + if (fields.length > 0) { + if (numCols === 0) numCols = fields.length; + enqueue(fields); + idx++; } ctx.tail = ""; ctx.numCols = numCols; @@ -574,15 +585,8 @@ export const csvQuotedParser = (text, options = {}, isFlushing = false) => { const ctx = { delimiterChar, - delimiterCharCode: options.delimiterCharCode ?? delimiterChar.charCodeAt(0), delimiterCharLength: options.delimiterCharLength ?? delimiterChar.length, - delimiterCharSingle: - options.delimiterCharSingle ?? delimiterChar.length === 1, newlineChar, - newlineCharCode: options.newlineCharCode ?? newlineChar.charCodeAt(0), - newlineCharSingle: - options.newlineCharSingle ?? - (newlineChar.length > 1 ? newlineChar.charCodeAt(1) : -1), newlineCharLength: options.newlineCharLength ?? newlineChar.length, quoteChar, quoteCharCode: options.quoteCharCode ?? quoteChar.charCodeAt(0), @@ -590,9 +594,10 @@ export const csvQuotedParser = (text, options = {}, isFlushing = false) => { escapeCharCode: options.escapeCharCode ?? escapeChar.charCodeAt(0), escapeIsQuote: options.escapeIsQuote ?? escapeChar === quoteChar, escapedQuote: options.escapedQuote ?? escapeChar + quoteChar, + fieldMaxSize: options.fieldMaxSize ?? Number.POSITIVE_INFINITY, numCols: options.numCols ?? 0, idx: options.idx ?? 0, - tail: "", + // `tail`/`errors` are always assigned by csvParseInline before being read. errors: null, }; const rows = []; @@ -650,11 +655,11 @@ const csvSteamifyParser = (options = {}) => { escapeChar, fieldMaxSize, } = options; - const useCustomParser = parser != null; parser ??= csvQuotedParser; - let resolved = false; - const ctx = { numCols: 0, idx: 0, tail: "", errors: null }; + // Per-chunk parser context; every field is (re)assigned by resolveOptions and + // the parser result before it is read (numCols/idx default via `?? 0`). + const ctx = {}; let buffer = ""; const errors = {}; @@ -679,13 +684,8 @@ const csvSteamifyParser = (options = {}) => { escapeChar = resolveLazy(escapeChar) ?? quoteChar; ctx.delimiterChar = delimiterChar; - ctx.delimiterCharCode = delimiterChar.charCodeAt(0); ctx.delimiterCharLength = delimiterChar.length; - ctx.delimiterCharSingle = delimiterChar.length === 1; ctx.newlineChar = newlineChar; - ctx.newlineCharCode = newlineChar.charCodeAt(0); - ctx.newlineCharSingle = - newlineChar.length > 1 ? newlineChar.charCodeAt(1) : -1; ctx.newlineCharLength = newlineChar.length; ctx.quoteChar = quoteChar; ctx.quoteCharCode = quoteChar.charCodeAt(0); @@ -693,56 +693,42 @@ const csvSteamifyParser = (options = {}) => { ctx.escapeCharCode = escapeChar.charCodeAt(0); ctx.escapeIsQuote = escapeChar === quoteChar; ctx.escapedQuote = escapeChar + quoteChar; - ctx.fieldMaxSize = fieldMaxSize ?? 16_777_216; - resolved = true; + ctx.fieldMaxSize = fieldMaxSize; }; const streamFn = (chunk, enqueue) => { - if (!resolved) resolveOptions(); - const str = typeof chunk === "string" ? chunk : chunk.toString(); - const text = buffer.length > 0 ? buffer + str : str; - buffer = ""; + // resolveLazy is idempotent on already-resolved values, so re-resolving on + // every chunk is safe and keeps lazy options deferred until upstream runs. + resolveOptions(); + // String#toString returns the string itself, so this also handles string + // chunks; an empty buffer concatenates to just the chunk. + const text = buffer + chunk.toString(); + // `buffer` is reassigned from the parse tail below before it is read again. if (text.length > ctx.fieldMaxSize * 2) { throw new Error( `CSV buffer size (${text.length}) exceeds safety limit, likely unterminated quoted field`, ); } - if (useCustomParser) { - const result = parser(text, ctx, false); - ctx.numCols = result.numCols; - ctx.idx = result.idx; - buffer = result.tail; - if (result.errors) mergeErrors(result.errors); - const rows = result.rows; - for (let i = 0; i < rows.length; i++) enqueue(rows[i]); - } else { - ctx.tail = ""; - ctx.errors = null; - csvParseInline(text, ctx, false, enqueue); - buffer = ctx.tail; - if (ctx.errors !== null) mergeErrors(ctx.errors); - } + const result = parser(text, ctx, false); + ctx.numCols = result.numCols; + ctx.idx = result.idx; + buffer = result.tail; + mergeErrors(result.errors); + const rows = result.rows; + for (let i = 0; i < rows.length; i++) enqueue(rows[i]); }; streamFn.flush = (enqueue) => { - if (!resolved) resolveOptions(); + resolveOptions(); if (buffer.length > 0) { const remaining = buffer; - buffer = ""; - if (useCustomParser) { - const result = parser(remaining, ctx, true); - ctx.numCols = result.numCols; - ctx.idx = result.idx; - if (result.errors) mergeErrors(result.errors); - const rows = result.rows; - for (let i = 0; i < rows.length; i++) enqueue(rows[i]); - } else { - ctx.tail = ""; - ctx.errors = null; - csvParseInline(remaining, ctx, true, enqueue); - if (ctx.errors !== null) mergeErrors(ctx.errors); - } + const result = parser(remaining, ctx, true); + ctx.numCols = result.numCols; + ctx.idx = result.idx; + mergeErrors(result.errors); + const rows = result.rows; + for (let i = 0; i < rows.length; i++) enqueue(rows[i]); } }; @@ -754,42 +740,22 @@ const csvSteamifyParser = (options = {}) => { export const csvParseStream = (options = {}, streamOptions = {}) => { const { - chunkSize = 2_097_152, // 2MB + // chunkSize is accepted for backwards compatibility; the streaming parser + // buffers partial rows itself, so chunks are parsed as they arrive. + chunkSize: _chunkSize, fieldMaxSize = 16_777_216, // 16MB resultKey, ...parserOptions } = options; parserOptions.fieldMaxSize = fieldMaxSize; - streamOptions.highWaterMark ??= 16384; const streamParse = csvSteamifyParser(parserOptions); - let inputChunks = []; - let inputLen = 0; - let ready = false; - const transform = (chunk, enqueue) => { - if (!ready) { - inputChunks.push(chunk); - inputLen += chunk.length; - if (inputLen < chunkSize) return; - ready = true; - const text = - inputChunks.length === 1 ? inputChunks[0] : inputChunks.join(""); - inputChunks = null; - streamParse(text, enqueue); - } else { - streamParse(chunk, enqueue); - } + streamParse(chunk, enqueue); }; const flush = (enqueue) => { - if (!ready && inputLen > 0) { - const text = - inputChunks.length === 1 ? inputChunks[0] : inputChunks.join(""); - inputChunks = null; - streamParse(text, enqueue); - } streamParse.flush(enqueue); }; @@ -850,9 +816,8 @@ export const csvRemoveEmptyRowsStream = (options = {}, streamOptions = {}) => { let idx = -1; const isEmpty = (chunk) => { - const l = chunk.length; - if (l === 0) return true; - for (let i = 0; i < l; i++) { + // A zero-length row falls through the loop and returns true as well. + for (let i = 0; i < chunk.length; i++) { if (chunk[i] !== "") return false; } return true; @@ -891,33 +856,22 @@ const autoCoerce = (val) => { const len = val.length; if (len === 0) return null; const c0 = val.charCodeAt(0); - // Fast boolean check: avoid toLowerCase() for non-boolean strings - if (len === 4 && (c0 === 116 || c0 === 84)) { - // 't' or 'T' - const lower = val.toLowerCase(); - if (lower === "true") return true; - } else if (len === 5 && (c0 === 102 || c0 === 70)) { - // 'f' or 'F' - const lower = val.toLowerCase(); - if (lower === "false") return false; + const lower = val.toLowerCase(); + if (lower === "true") return true; + if (lower === "false") return false; + // Number then ISO date — both regexes are anchored and only match + // digit/minus-prefixed strings, so non-numeric input falls through. + if (numberRe.test(val)) return Number(val); + if (iso8601Re.test(val)) { + const d = new Date(val); + if (!Number.isNaN(d.getTime())) return d; } - // Number: starts with digit or minus sign - if ( - (c0 >= 48 && c0 <= 57) || // '0'-'9' - c0 === 45 // '-' - ) { - if (numberRe.test(val)) return Number(val); - if (iso8601Re.test(val)) return new Date(val); - return val; - } - // ISO date: starts with digit (already handled above) - // JSON: starts with '{' or '[' + // JSON: only attempt for '{' or '[' so values like "null" are not parsed. + // On a parse error fall through to the final `return val`. if (c0 === 123 || c0 === 91) { try { return JSON.parse(val); - } catch { - return val; - } + } catch {} } return val; }; @@ -939,12 +893,13 @@ const coerceToType = (val, type) => { const d = new Date(val); return Number.isNaN(d.getTime()) ? val : d; } - case "json": + case "json": { try { return JSON.parse(val); - } catch { - return val; - } + } catch {} + // On a JSON parse error, keep the original string. + return val; + } default: return val; } @@ -998,58 +953,34 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { const quoteChar = options.quoteChar ?? defaultQuoteChar; const escapeChar = options.escapeChar ?? quoteChar; - // Pre-compute char codes and flags once at stream creation - const delimiterCode = delimiterChar.charCodeAt(0); - const delimiterSingle = delimiterChar.length === 1; - const quoteCode = quoteChar.charCodeAt(0); + // Pre-compute escaping flags/strings once at stream creation const escapeIsQuote = escapeChar === quoteChar; const escapedQuote = escapeChar + quoteChar; const escapedEscape = escapeChar + escapeChar; - // Single-pass charCode scan for single-char delimiter (common case), - // includes fallback for multi-char - const scanNeedsQuote = delimiterSingle - ? (value) => { - const len = value.length; - const first = value.charCodeAt(0); - // = (61) + (43) - (45) @ (64) space (32) BOM (FEFF) - if ( - first === 61 || - first === 43 || - first === 45 || - first === 64 || - first === 32 || - first === 0xfeff - ) - return true; - if (value.charCodeAt(len - 1) === 32) return true; - for (let i = 0; i < len; i++) { - const c = value.charCodeAt(i); - if (c === delimiterCode || c === quoteCode || c === 13 || c === 10) - return true; - } - return false; - } - : (value) => { - const len = value.length; - const first = value.charCodeAt(0); - if ( - first === 61 || - first === 43 || - first === 45 || - first === 64 || - first === 32 || - first === 0xfeff - ) - return true; - if (value.charCodeAt(len - 1) === 32) return true; - if (value.includes(delimiterChar)) return true; - for (let i = 0; i < len; i++) { - const c = value.charCodeAt(i); - if (c === quoteCode || c === 13 || c === 10) return true; - } - return false; - }; + // A field must be quoted when it starts with a formula/whitespace/BOM + // trigger, ends with a space, or contains the delimiter, the quote char, + // or a CR/LF. The leading-char trigger is checked by code; the rest are + // substring containment checks (delimiter may be multi-char). + const startsWithTrigger = (value) => { + // = (61) + (43) - (45) @ (64) space (32) BOM (FEFF) + const first = value.charCodeAt(0); + return ( + first === 61 || + first === 43 || + first === 45 || + first === 64 || + first === 32 || + first === 0xfeff + ); + }; + const scanNeedsQuote = (value) => + startsWithTrigger(value) || + value.charCodeAt(value.length - 1) === 32 || + value.includes(delimiterChar) || + value.includes(quoteChar) || + value.includes("\r") || + value.includes("\n"); // Skip replaceAll when value has no chars that need escaping (common: // field quoted because of delimiter/newline, but contains no quote chars) @@ -1067,39 +998,22 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { : quoteChar + v + quoteChar; }; - // Fast path: all fields are strings (or null/undefined) and none need - // quoting → use Array.join (single native allocation + memcpy) instead - // of per-field ConsString concatenation. join converts null/undefined - // to "" which matches empty-field CSV semantics. - const isSimpleRow = (chunk) => { + // Build one row string: coerce each field, quote/escape where required, + // then join with the delimiter (one flat string per row). + const formatRow = (chunk) => { + const parts = []; for (let i = 0; i < chunk.length; i++) { - const val = chunk[i]; - if (val == null) continue; - if (typeof val !== "string") return false; - if (val.length > 0 && scanNeedsQuote(val)) return false; - } - return true; - }; - - // Slow path: pre-allocated parts array + join (produces flat string - // directly, avoids ~2n ConsString nodes from per-field concatenation) - const formatRowSlow = (chunk) => { - const len = chunk.length; - const parts = new Array(len); - for (let i = 0; i < len; i++) { - let val = chunk[i]; - if (val == null) { - parts[i] = ""; + const raw = chunk[i]; + if (raw == null) { + // null/undefined → empty field + parts.push(""); continue; } - if (typeof val !== "string") { - val = val instanceof Date ? val.toISOString() : String(val); - } - if (val.length === 0) { - parts[i] = ""; - continue; - } - parts[i] = scanNeedsQuote(val) ? wrapQuote(val) : val; + // Strings pass through String() unchanged; Dates use ISO 8601. An empty + // string is never a quoting trigger, so scanNeedsQuote handles it + // directly without a special case. + const val = raw instanceof Date ? raw.toISOString() : String(raw); + parts.push(scanNeedsQuote(val) ? wrapQuote(val) : val); } return parts.join(delimiterChar); }; @@ -1110,9 +1024,7 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { const batch = []; const transform = (chunk, enqueue) => { - batch.push( - isSimpleRow(chunk) ? chunk.join(delimiterChar) : formatRowSlow(chunk), - ); + batch.push(formatRow(chunk)); if (batch.length >= 64) { enqueue(batch.join(newlineChar) + newlineChar); batch.length = 0; @@ -1129,7 +1041,27 @@ export const csvFormatStream = (options = {}, streamOptions = {}) => { return createTransformStream(transform, flush, streamOptions); }; -export const csvArrayToObject = ({ headers }, streamOptions) => - objectFromEntriesStream({ keys: headers }, streamOptions); +export const csvArrayToObject = ({ headers }, streamOptions = {}) => { + let resolvedKeys; + const transform = (chunk, enqueue) => { + resolvedKeys ??= resolveLazy(headers); + const value = {}; + for (let i = 0; i < resolvedKeys.length; i++) { + // defineProperty is used for every column so reserved keys such as + // "__proto__" become own enumerable data properties instead of mutating + // the object's prototype (which a plain `value[key] = ...` would do, + // silently dropping the column). For ordinary keys this is equivalent + // to a normal assignment. + Object.defineProperty(value, resolvedKeys[i], { + value: chunk[i], + writable: true, + enumerable: true, + configurable: true, + }); + } + enqueue(value); + }; + return createTransformStream(transform, streamOptions); +}; export const csvObjectToArray = ({ headers }, streamOptions) => objectToEntriesStream({ keys: headers }, streamOptions); diff --git a/packages/csv/index.test.js b/packages/csv/index.test.js index e11aaaa..2eddaef 100644 --- a/packages/csv/index.test.js +++ b/packages/csv/index.test.js @@ -1773,3 +1773,3379 @@ test(`${variant}: csvCoerceValuesStream should handle uppercase boolean`, async deepStrictEqual(output, [{ val: true, val2: false }]); }); + +// *** FINDING: fast-path-too-many-columns-merged *** // +test(`${variant}: csvParseStream should not merge extra columns in fast unquoted path`, async (_t) => { + // A row with MORE fields than the established numCols must keep all + // fields separate (not collapse the surplus into the last field). + const streams = [ + createReadableStream("a,b,c\r\n1,2,3,4,5\r\n"), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["a", "b", "c"], + ["1", "2", "3", "4", "5"], + ]); +}); + +test(`${variant}: csvParseStream fast and slow paths agree on over-long rows`, async (_t) => { + // Identical row data must parse identically whether or not a quote char + // appears elsewhere in the buffer (quote presence picks fast vs slow path). + const fastStreams = [ + createReadableStream("a,b,c\r\n1,2,3,4,5\r\n"), + csvParseStream(), + ]; + const slowStreams = [ + createReadableStream('a,b,c\r\n1,2,3,4,5\r\n"x",y,z\r\n'), + csvParseStream(), + ]; + const fast = await streamToArray(pipejoin(fastStreams)); + const slow = await streamToArray(pipejoin(slowStreams)); + + deepStrictEqual(fast[1], ["1", "2", "3", "4", "5"]); + deepStrictEqual(slow[1], ["1", "2", "3", "4", "5"]); +}); + +test(`${variant}: csvParseStream over-long rows are detectable as malformed`, async (_t) => { + const filter = csvRemoveMalformedRowsStream(); + const streams = [ + createReadableStream("a,b,c\r\n1,2,3,4,5\r\n6,7,8\r\n"), + csvParseStream(), + filter, + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["a", "b", "c"], + ["6", "7", "8"], + ]); + deepStrictEqual(filter.result().value.MalformedRow.idx, [1]); +}); + +// *** FINDING: detect-quote-scans-whole-buffer *** // +test(`${variant}: csvDetectDelimitersStream should not detect apostrophe in data as quote char`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [ + createReadableStream("quote,author\n'twas the night,Moore\nhello,World\n"), + detect, + ]; + await pipeline(streams); + + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvParseStream should parse data with apostrophes after auto-detect`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const parse = csvParseStream({ + delimiterChar: () => detect.result().value.delimiterChar, + newlineChar: () => detect.result().value.newlineChar, + quoteChar: () => detect.result().value.quoteChar, + }); + const streams = [ + createReadableStream("quote,author\n'twas the night,Moore\nhello,World\n"), + detect, + parse, + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["quote", "author"], + ["'twas the night", "Moore"], + ["hello", "World"], + ]); +}); + +// *** FINDING: custom-escape-not-inverse-of-format *** // +test(`${variant}: csvFormatStream/csvParseStream round-trip preserves escape char with custom escapeChar`, async (_t) => { + const value = 'a"b\\c'; + const formatStreams = [ + createReadableStream([[value, "next"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const formatted = await streamToString(pipejoin(formatStreams)); + + const parseStreams = [ + createReadableStream(formatted), + csvParseStream({ escapeChar: "\\" }), + ]; + const parsed = await streamToArray(pipejoin(parseStreams)); + + deepStrictEqual(parsed, [[value, "next"]]); +}); + +// *** FINDING: custom-escape-lookback-parity *** // +test(`${variant}: csvParseStream should treat even run of escape chars as unescaped closing quote`, async (_t) => { + // "a\\" => field value is a\ (escaped backslash), then the closing quote + // is real, so the delimiter and following field are not swallowed. + const streams = [ + createReadableStream('"a\\\\",b\r\nc,d\r\n'), + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [ + ["a\\", "b"], + ["c", "d"], + ]); +}); + +// *** FINDING: detect-header-naive-newline-split *** // +test(`${variant}: csvDetectHeaderStream should respect quoted newline in header field`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [ + createReadableStream('"col\nwith newline",col2\nval1,val2\n'), + headers, + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + + deepStrictEqual(headers.result().value.header, ["col\nwith newline", "col2"]); + strictEqual(output, "val1,val2\n"); +}); + +test(`${variant}: csvDetectHeaderStream should respect custom-escape quoted newline in header`, async (_t) => { + const headers = csvDetectHeaderStream({ + newlineChar: "\n", + escapeChar: "\\", + }); + const streams = [ + createReadableStream('"col\\"x\nnext",col2\nval1,val2\n'), + headers, + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + + deepStrictEqual(headers.result().value.header, ['col"x\nnext', "col2"]); + strictEqual(output, "val1,val2\n"); +}); + +// *** FINDING: autocoerce-invalid-date *** // +test(`${variant}: csvCoerceValuesStream should keep invalid date string as string`, async (_t) => { + const streams = [ + createReadableStream([{ d: "2024-13-99" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + deepStrictEqual(output, [{ d: "2024-13-99" }]); +}); + +// *** FINDING: csvArrayToObject-proto-header-drops-column *** // +test(`${variant}: csvArrayToObject should keep a __proto__ header as an own data property`, async (_t) => { + const streams = [ + createReadableStream([["polluted", "ok"]]), + csvArrayToObject({ headers: ["__proto__", "safe"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + + strictEqual(output.length, 1); + const row = output[0]; + // The __proto__ column must be stored as an own data property, not lost. + ok(Object.hasOwn(row, "__proto__")); + // biome-ignore lint/suspicious/noProto: intentionally reading the own data property literally named "__proto__" to assert prototype-pollution safety. + strictEqual(row.__proto__, "polluted"); + strictEqual(row.safe, "ok"); +}); + +// =================================================================== +// Mutation-killing tests (added to raise mutation score) +// =================================================================== + +// --- csvFormatStream: scanNeedsQuote first-char triggers --- +test(`${variant}: csvFormatStream should quote field with leading space`, async (_t) => { + const streams = [createReadableStream([[" lead", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '" lead",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with trailing space`, async (_t) => { + const streams = [createReadableStream([["trail ", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"trail ",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with leading BOM`, async (_t) => { + const streams = [createReadableStream([["bom", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"bom",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should not quote plain interior space`, async (_t) => { + const streams = [createReadableStream([["a b c", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "a b c,ok\r\n"); +}); + +test(`${variant}: csvFormatStream should quote field containing carriage return`, async (_t) => { + const streams = [createReadableStream([["a\rb", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\rb",ok\r\n'); +}); + +// --- csvFormatStream: multi-char delimiter scan path --- +test(`${variant}: csvFormatStream should quote value containing multi-char delimiter`, async (_t) => { + const streams = [ + createReadableStream([["a::b", "plain"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a::b"::plain\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should quote on quote char`, async (_t) => { + const streams = [ + createReadableStream([['has"quote', "plain"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"has""quote"::plain\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should not quote plain value`, async (_t) => { + const streams = [ + createReadableStream([["plain", "value"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "plain::value\r\n"); +}); + +// --- csvFormatStream: wrapQuote with custom escape --- +test(`${variant}: csvFormatStream custom escape should escape escape char and quote char`, async (_t) => { + // escapeChar=\, value contains both \ and "; backslash doubled, quote -> \" + const streams = [ + createReadableStream([['a"b\\c', "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\\"b\\\\c",ok\r\n'); +}); + +test(`${variant}: csvFormatStream custom escape should escape only escape char when no quote`, async (_t) => { + const streams = [ + createReadableStream([["a\\b,c", "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const output = await streamToString(pipejoin(streams)); + // contains delimiter so quoted; backslash doubled; no quote char present + strictEqual(output, '"a\\\\b,c",ok\r\n'); +}); + +// --- csvFormatStream: Date and number/non-string formatting (formatRowSlow) --- +test(`${variant}: csvFormatStream should format Date as ISO string`, async (_t) => { + const d = new Date("2024-01-15T10:30:00.000Z"); + const streams = [createReadableStream([[d, "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "2024-01-15T10:30:00.000Z,ok\r\n"); +}); + +test(`${variant}: csvFormatStream should format number via String`, async (_t) => { + const streams = [createReadableStream([[42, 3.14]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "42,3.14\r\n"); +}); + +test(`${variant}: csvFormatStream should format null and undefined as empty`, async (_t) => { + const streams = [ + createReadableStream([[null, undefined, "x"]]), + csvFormatStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, ",,x\r\n"); +}); + +test(`${variant}: csvFormatStream should format boolean via String in slow path`, async (_t) => { + // boolean true forces slow path (non-string); String(true) === "true" + const streams = [createReadableStream([[true, false]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "true,false\r\n"); +}); + +test(`${variant}: csvFormatStream should quote number-derived string needing quote`, async (_t) => { + // A negative number stringifies to "-42" which starts with '-', a quote trigger + const streams = [createReadableStream([[-42, "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"-42",ok\r\n'); +}); + +// --- csvFormatStream: batch boundary (>= 64 rows flushed mid-stream) --- +test(`${variant}: csvFormatStream should emit batch at 64 rows then flush remainder`, async (_t) => { + const rows = []; + for (let i = 0; i < 70; i++) rows.push([String(i), "x"]); + const streams = [createReadableStream(rows), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + const lines = output.split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 70); + strictEqual(lines[0], "0,x"); + strictEqual(lines[69], "69,x"); +}); + +// --- csvFormatStream: custom newlineChar separator --- +test(`${variant}: csvFormatStream should use custom newlineChar`, async (_t) => { + const streams = [ + createReadableStream([ + ["1", "2"], + ["3", "4"], + ]), + csvFormatStream({ newlineChar: "\n" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "1,2\n3,4\n"); +}); + +// --- csvFormatStream: custom quoteChar --- +test(`${variant}: csvFormatStream should use custom quoteChar`, async (_t) => { + const streams = [ + createReadableStream([["a,b", "ok"]]), + csvFormatStream({ quoteChar: "'" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "'a,b',ok\r\n"); +}); + +// --- csvFormatStream multi-char delimiter: trailing space / CR / LF triggers --- +test(`${variant}: csvFormatStream multi-char delimiter should quote trailing space`, async (_t) => { + const streams = [ + createReadableStream([["trail ", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"trail "::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should quote on newline`, async (_t) => { + const streams = [ + createReadableStream([["a\nb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\nb"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter should quote on carriage return`, async (_t) => { + const streams = [ + createReadableStream([["a\rb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\rb"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter should quote on linefeed only`, async (_t) => { + const streams = [createReadableStream([["a\nb", "ok"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a\nb",ok\r\n'); +}); + +// --- csvCoerceValuesStream: number regex exponent shape --- +test(`${variant}: csvCoerceValuesStream should coerce exponent with explicit sign and multi-digit exponent`, async (_t) => { + const streams = [ + createReadableStream([{ a: "1e+10", b: "2e-05", c: "1.5E3" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 1e10, b: 2e-5, c: 1500 }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep malformed exponent as string`, async (_t) => { + const streams = [ + createReadableStream([{ a: "1e", b: "1e+", c: "1.2.3" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "1e", b: "1e+", c: "1.2.3" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce string with trailing non-number text`, async (_t) => { + const streams = [ + createReadableStream([{ a: "12abc", d: "2024-01-15extra" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "12abc", d: "2024-01-15extra" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce date missing leading anchor`, async (_t) => { + const streams = [ + createReadableStream([{ d: "x2024-01-15" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ d: "x2024-01-15" }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with time and timezone offset`, async (_t) => { + const streams = [ + createReadableStream([{ d: "2024-01-15T10:30:00+05:30" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00+05:30") }]); +}); + +// --- csvCoerceValuesStream: boolean first-char/length gating in autoCoerce --- +test(`${variant}: csvCoerceValuesStream should not treat 4-char non-true string as boolean`, async (_t) => { + const streams = [ + createReadableStream([{ a: "trUE", b: "tree", c: "True" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: true, b: "tree", c: true }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce non-t/f boolean-length words`, async (_t) => { + const streams = [ + createReadableStream([{ a: "yes", b: "nope" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "yes", b: "nope" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce truthy when length is wrong`, async (_t) => { + const streams = [ + createReadableStream([{ a: "truer", b: "falsey" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "truer", b: "falsey" }]); +}); + +test(`${variant}: csvCoerceValuesStream should return null for empty string auto-coerce`, async (_t) => { + const streams = [createReadableStream([{ a: "" }]), csvCoerceValuesStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: null }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce single-digit zero and nine`, async (_t) => { + const streams = [ + createReadableStream([{ a: "0", b: "9" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 0, b: 9 }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep plain non-numeric non-bool string`, async (_t) => { + const streams = [ + createReadableStream([{ a: "hello", b: "world" }]), + csvCoerceValuesStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "hello", b: "world" }]); +}); + +// --- coerceToType boolean: string vs non-string --- +test(`${variant}: csvCoerceValuesStream explicit boolean should lowercase compare string`, async (_t) => { + const streams = [ + createReadableStream([{ a: "TRUE", b: "no", c: "true" }]), + csvCoerceValuesStream({ + columns: { a: "boolean", b: "boolean", c: "boolean" }, + }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: true, b: false, c: true }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit boolean should use Boolean for non-string`, async (_t) => { + const streams = [ + createReadableStream([{ a: 1, b: 0 }]), + csvCoerceValuesStream({ columns: { a: "boolean", b: "boolean" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: true, b: false }]); +}); + +// --- coerceToType json with array --- +test(`${variant}: csvCoerceValuesStream explicit json should parse array`, async (_t) => { + const streams = [ + createReadableStream([{ a: "[1,2,3]" }]), + csvCoerceValuesStream({ columns: { a: "json" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: [1, 2, 3] }]); +}); + +// --- coerceToType default (unknown type) passthrough --- +test(`${variant}: csvCoerceValuesStream unknown column type should pass value unchanged`, async (_t) => { + const streams = [ + createReadableStream([{ a: "raw" }]), + csvCoerceValuesStream({ columns: { a: "string" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: "raw" }]); +}); + +// --- coerceToType number coerces hex-like and keeps decimals --- +test(`${variant}: csvCoerceValuesStream explicit number coerces decimal and hex`, async (_t) => { + const streams = [ + createReadableStream([{ a: "3.14", b: "0x1F" }]), + csvCoerceValuesStream({ columns: { a: "number", b: "number" } }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [{ a: 3.14, b: 31 }]); +}); + +// --- csvDetectDelimitersStream: quote bracketing precision --- +// A quote candidate must OPEN at a field start AND CLOSE at a field end to be +// recognised. The following exercise each isFieldStart / isFieldEnd branch. + +test(`${variant}: csvDetectDelimitersStream should detect quote opening at text start`, async (_t) => { + // "'a'" opens at i===0 and closes before a delimiter + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("'a',b\n1,2\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should detect quote after delimiter and closing at line end`, async (_t) => { + // second field 'b' opens after a comma and closes before the newline (LF) + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a,'b'\n1,2\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should detect quote closing before CR`, async (_t) => { + // quoted field closes right before \r in a CRLF newline + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("'a',b\r\n1,2\r\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); + strictEqual(detect.result().value.newlineChar, "\r\n"); +}); + +test(`${variant}: csvDetectDelimitersStream should detect quote opening after newline`, async (_t) => { + // The only properly-bracketed quote is on row 2, opening right after the LF + const detect = csvDetectDelimitersStream({ chunkSize: 0 }); + const streams = [createReadableStream("a,b\n'c',d\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should NOT detect quote that never closes at a field end`, async (_t) => { + // 'x is a field-start apostrophe with no closing quote at any field end. + // A later lone apostrophe sits mid-field (after a letter, not a boundary), + // so it neither opens nor closes a field -> default double-quote. + const detect = csvDetectDelimitersStream(); + const streams = [ + createReadableStream("name,note\n'x,it's fine\nhello,world\n"), + detect, + ]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream should set escapeChar equal to detected quoteChar`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("'a','b'\n1,2\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.escapeChar, "'"); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream should pick first delimiter by precedence (tab over comma)`, async (_t) => { + // Header contains BOTH a tab and a comma; detection order is [tab,pipe,semi,comma] + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a\tb,c\n1\t2,3\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.delimiterChar, "\t"); +}); + +test(`${variant}: csvDetectDelimitersStream should pick pipe over semicolon and comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a|b;c,d\n1|2;3,4\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.delimiterChar, "|"); +}); + +test(`${variant}: csvDetectDelimitersStream should pick semicolon over comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a;b,c\n1;2,3\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +test(`${variant}: csvDetectDelimitersStream should default to double-quote when no quote present`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a,b,c\n1,2,3\n"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream should set newlineChar from header match`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const streams = [createReadableStream("a,b\r1,2\r"), detect]; + await pipeline(streams); + strictEqual(detect.result().value.newlineChar, "\r"); +}); + +// --- csvQuotedParser direct: numCols, escapes, tail, multi-char newline --- +test(`${variant}: csvQuotedParser establishes numCols from first row`, (_t) => { + const result = csvQuotedParser("a,b,c\r\n1,2,3\r\n"); + strictEqual(result.numCols, 3); + deepStrictEqual(result.rows, [ + ["a", "b", "c"], + ["1", "2", "3"], + ]); +}); + +test(`${variant}: csvQuotedParser returns tail for incomplete trailing row`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2\r\n3,4"); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["1", "2"], + ]); + strictEqual(result.tail, "3,4"); +}); + +test(`${variant}: csvQuotedParser preserves quoted field with escaped quotes (no escapes flag)`, (_t) => { + const result = csvQuotedParser('"plain",x\r\n'); + deepStrictEqual(result.rows, [["plain", "x"]]); +}); + +test(`${variant}: csvQuotedParser keeps doubled-quote escapes`, (_t) => { + const result = csvQuotedParser('"a""b","c"\r\n'); + deepStrictEqual(result.rows, [['a"b', "c"]]); +}); + +test(`${variant}: csvQuotedParser handles short quoted row truncating to fi`, (_t) => { + // numCols established as 3, second row has a single quoted field -> length 1 + const result = csvQuotedParser('a,b,c\r\n"d"\r\n'); + deepStrictEqual(result.rows[0], ["a", "b", "c"]); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.rows[1], ["d"]); +}); + +test(`${variant}: csvQuotedParser handles multi-char newline length>2`, (_t) => { + const result = csvQuotedParser("a,b||~c,d||~", { newlineChar: "||~" }, true); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser CRLF requires both chars (lone CR does not split)`, (_t) => { + // With CRLF newline, a lone \r mid-field must NOT terminate the row. + const result = csvQuotedParser('"a\rb",c\r\n', { newlineChar: "\r\n" }, true); + deepStrictEqual(result.rows, [["a\rb", "c"]]); +}); + +test(`${variant}: csvQuotedParser tracks unterminated quote error on flush`, (_t) => { + const result = csvQuotedParser('"abc', {}, true); + deepStrictEqual(result.rows, [["abc"]]); + ok(result.errors.UnterminatedQuote); + deepStrictEqual(result.errors.UnterminatedQuote.idx, [0]); +}); + +test(`${variant}: csvQuotedParser unterminated quote not flushing returns tail`, (_t) => { + const result = csvQuotedParser('a,b\r\n"abc', {}, false); + deepStrictEqual(result.rows, [["a", "b"]]); + strictEqual(result.tail, '"abc'); +}); + +test(`${variant}: csvQuotedParser custom escape unterminated keeps unescaped value on flush`, (_t) => { + const result = csvQuotedParser('"a\\"b', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [['a"b']]); + ok(result.errors.UnterminatedQuote); +}); + +test(`${variant}: csvQuotedParser custom escape even run is real closing quote`, (_t) => { + const result = csvQuotedParser('"a\\\\",b\r\n', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a\\", "b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape odd run escapes the quote`, (_t) => { + const result = csvQuotedParser('"a\\"b",c\r\n', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [['a"b', "c"]]); +}); + +// --- field size limit --- +test(`${variant}: csvParseStream should throw when quoted field exceeds fieldMaxSize`, async (_t) => { + // field length 11 (> 10) but buffer (17) stays under the 2x safety limit (20) + const val = "x".repeat(11); + const streams = [ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10 }), + ]; + let threw = false; + try { + await pipeline(streams); + } catch (e) { + threw = true; + ok(/CSV field size/.test(e.message)); + } + ok(threw); +}); + +test(`${variant}: csvParseStream should accept quoted field at exactly fieldMaxSize`, async (_t) => { + const val = "x".repeat(10); + const streams = [ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [[val, "y"]]); +}); + +test(`${variant}: csvParseStream should throw when custom-escape quoted field exceeds fieldMaxSize`, async (_t) => { + const val = "x".repeat(11); + const streams = [ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ escapeChar: "\\", fieldMaxSize: 10 }), + ]; + let threw = false; + try { + await pipeline(streams); + } catch (e) { + threw = true; + ok(/CSV field size/.test(e.message)); + } + ok(threw); +}); + +test(`${variant}: csvParseStream should throw on buffer exceeding safety limit`, async (_t) => { + // An unterminated quote that grows the buffer beyond fieldMaxSize*2 throws. + const big = `"${"x".repeat(60)}`; + const streams = [ + createReadableStream(big), + csvParseStream({ fieldMaxSize: 10, chunkSize: 1 }), + ]; + let threw = false; + try { + await pipeline(streams); + } catch (e) { + threw = true; + ok(/safety limit/.test(e.message)); + } + ok(threw); +}); + +// --- fast unquoted path: too-few columns (malformed) and surplus --- +test(`${variant}: csvParseStream fast path keeps too-few-column row as-is`, async (_t) => { + const streams = [ + createReadableStream("a,b,c\r\nd,e\r\nf,g,h\r\n"), + csvParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b", "c"], + ["d", "e"], + ["f", "g", "h"], + ]); +}); + +test(`${variant}: csvParseStream fast path keeps multiple full rows`, async (_t) => { + const streams = [ + createReadableStream("a,b\r\n1,2\r\n3,4\r\n5,6\r\n"), + csvParseStream(), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]); +}); + +test(`${variant}: csvParseStream fast path partial last row without newline`, async (_t) => { + const streams = [createReadableStream("a,b\r\n1,2\r\n3,4"), csvParseStream()]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ]); +}); + +// --- multi-char newline (length 2 custom, not CRLF) --- +test(`${variant}: csvParseStream supports 2-char custom newline`, async (_t) => { + const streams = [ + createReadableStream("a,b~|1,2~|"), + csvParseStream({ newlineChar: "~|" }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +test(`${variant}: csvParseStream 2-char newline does not split on partial match`, async (_t) => { + // A lone '~' that is not followed by '|' must stay inside the field. + const streams = [ + createReadableStream('"a~b",c~|d,e~|'), + csvParseStream({ newlineChar: "~|" }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a~b", "c"], + ["d", "e"], + ]); +}); + +// --- unquoted parser numCols + multi-row --- +test(`${variant}: csvUnquotedParser sets numCols and parses many rows`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2\r\n3,4\r\n"); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ]); +}); + +test(`${variant}: csvUnquotedParser custom delimiter and newline`, (_t) => { + const result = csvUnquotedParser("a;b\nc;d\n", { + delimiterChar: ";", + newlineChar: "\n", + }); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvDetectHeaderStream / findRowEnd quote & escape aware row scan --- +test(`${variant}: csvDetectHeaderStream multi-char delimiter splits header`, async (_t) => { + const headers = csvDetectHeaderStream({ + newlineChar: "\n", + delimiterChar: "::", + }); + const streams = [createReadableStream("a::b::c\n1::2::3\n"), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a", "b", "c"]); + strictEqual(output, "1::2::3\n"); +}); + +test(`${variant}: csvDetectHeaderStream respects delimiter inside quoted header field`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [createReadableStream('"a,b",c\n1,2\n'), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a,b", "c"]); + strictEqual(output, "1,2\n"); +}); + +test(`${variant}: csvDetectHeaderStream custom-escape even run closes header quote`, async (_t) => { + const headers = csvDetectHeaderStream({ + newlineChar: "\n", + escapeChar: "\\", + }); + const streams = [createReadableStream('"a\\\\",b\nv1,v2\n'), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a\\", "b"]); + strictEqual(output, "v1,v2\n"); +}); + +test(`${variant}: csvDetectHeaderStream passes through data after a quoted header newline`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [ + createReadableStream('"h1\nstill h1",h2\nr1a,r1b\nr2a,r2b\n'), + headers, + ]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["h1\nstill h1", "h2"]); + strictEqual(output, "r1a,r1b\nr2a,r2b\n"); +}); + +test(`${variant}: csvDetectHeaderStream CRLF header newline length`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\r\n" }); + const streams = [createReadableStream("a,b,c\r\n1,2,3\r\n"), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["a", "b", "c"]); + strictEqual(output, "1,2,3\r\n"); +}); + +test(`${variant}: csvDetectHeaderStream emits remaining rest only when non-empty`, async (_t) => { + const headers = csvDetectHeaderStream({ newlineChar: "\n" }); + const streams = [createReadableStream("only,header\n"), headers]; + const output = await streamToString(pipejoin(streams)); + deepStrictEqual(headers.result().value.header, ["only", "header"]); + strictEqual(output, ""); +}); + +// --- csvRemoveMalformedRowsStream result key + default + idx --- +test(`${variant}: csvRemoveMalformedRowsStream default resultKey and message`, async (_t) => { + const filter = csvRemoveMalformedRowsStream(); + const streams = [createReadableStream([["a", "b"], ["c"]]), filter]; + const result = await pipeline(streams); + strictEqual(filter.result().key, "csvRemoveMalformedRows"); + deepStrictEqual(result.csvRemoveMalformedRows.MalformedRow.idx, [1]); + strictEqual( + filter.result().value.MalformedRow.message, + "Row has incorrect number of fields", + ); + strictEqual(filter.result().value.MalformedRow.id, "MalformedRow"); +}); + +test(`${variant}: csvRemoveMalformedRowsStream first row sets count, later mismatch dropped`, async (_t) => { + const filter = csvRemoveMalformedRowsStream(); + const streams = [ + createReadableStream([["a", "b", "c"], ["1", "2", "3"], ["x"]]), + filter, + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b", "c"], + ["1", "2", "3"], + ]); + deepStrictEqual(filter.result().value.MalformedRow.idx, [2]); +}); + +// --- csvRemoveEmptyRowsStream default resultKey + message --- +test(`${variant}: csvRemoveEmptyRowsStream default resultKey and message`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [ + createReadableStream([ + ["", ""], + ["1", "2"], + ]), + filter, + ]; + const result = await pipeline(streams); + strictEqual(filter.result().key, "csvRemoveEmptyRows"); + strictEqual(result.csvRemoveEmptyRows.EmptyRow.message, "Row is empty"); + strictEqual(result.csvRemoveEmptyRows.EmptyRow.id, "EmptyRow"); +}); + +test(`${variant}: csvRemoveEmptyRowsStream keeps row with one non-empty field`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [ + createReadableStream([ + ["", "x"], + ["", ""], + ]), + filter, + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [["", "x"]]); + deepStrictEqual(filter.result().value.EmptyRow.idx, [1]); +}); + +test(`${variant}: csvRemoveEmptyRowsStream keeps row whose only non-empty field is last`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [createReadableStream([["", "", "z"]]), filter]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [["", "", "z"]]); + deepStrictEqual(filter.result().value, {}); +}); + +// --- csvInjectHeaderStream injects header exactly once --- +test(`${variant}: csvInjectHeaderStream injects header exactly once`, async (_t) => { + const streams = [ + createReadableStream([ + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]), + csvInjectHeaderStream({ header: ["a", "b"] }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ["3", "4"], + ["5", "6"], + ]); +}); + +// --- csvParseStream chunkSize ready/join paths --- +test(`${variant}: csvParseStream single input chunk reaching chunkSize processes immediately`, async (_t) => { + const streams = [ + createReadableStream(["a,b,c,d,e\r\n", "1,2,3,4,5\r\n"]), + csvParseStream({ chunkSize: 5 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b", "c", "d", "e"], + ["1", "2", "3", "4", "5"], + ]); +}); + +test(`${variant}: csvParseStream below chunkSize joins on flush`, async (_t) => { + const streams = [ + createReadableStream(["a,", "b\r\n", "1,", "2\r\n"]), + csvParseStream({ chunkSize: 1000 }), + ]; + const output = await streamToArray(pipejoin(streams)); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +test(`${variant}: csvParseStream default resultKey is csvErrors`, async (_t) => { + const streams = [createReadableStream("a,b\r\n"), csvParseStream()]; + const result = await pipeline(streams); + strictEqual(streams[1].result().key, "csvErrors"); + deepStrictEqual(result.csvErrors, {}); +}); + +// --- csvFormatStream multi-char delimiter: each first-char formula trigger --- +test(`${variant}: csvFormatStream multi-char delimiter quotes leading equals`, async (_t) => { + const streams = [ + createReadableStream([["=x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"=x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading plus`, async (_t) => { + const streams = [ + createReadableStream([["+x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"+x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading minus`, async (_t) => { + const streams = [ + createReadableStream([["-x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"-x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading at-sign`, async (_t) => { + const streams = [ + createReadableStream([["@x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"@x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading space`, async (_t) => { + const streams = [ + createReadableStream([[" x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '" x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes leading BOM`, async (_t) => { + const streams = [ + createReadableStream([["x", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"x"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter does NOT quote plain leading char`, async (_t) => { + const streams = [ + createReadableStream([["xyz", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), "xyz::ok\r\n"); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes trailing space`, async (_t) => { + const streams = [ + createReadableStream([["trail ", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"trail "::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes on newline`, async (_t) => { + const a = await streamToString( + pipejoin([ + createReadableStream([["a\nb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(a, '"a\nb"::ok\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes on carriage return`, async (_t) => { + const b = await streamToString( + pipejoin([ + createReadableStream([["a\rb", "ok"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(b, '"a\rb"::ok\r\n'); +}); + +// --- csvFormatStream single-char delimiter: each first-char formula trigger --- +test(`${variant}: csvFormatStream single-char delimiter quotes leading at-sign`, async (_t) => { + const streams = [createReadableStream([["@x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"@x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes leading plus`, async (_t) => { + const streams = [createReadableStream([["+x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"+x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes leading minus`, async (_t) => { + const streams = [createReadableStream([["-x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"-x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes leading equals`, async (_t) => { + const streams = [createReadableStream([["=x", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"=x",ok\r\n'); +}); + +test(`${variant}: csvFormatStream single-char delimiter does NOT quote plain value`, async (_t) => { + const streams = [createReadableStream([["xyz", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), "xyz,ok\r\n"); +}); + +test(`${variant}: csvFormatStream single-char delimiter quotes trailing space`, async (_t) => { + const streams = [createReadableStream([["trail ", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"trail ",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with leading BOM`, async (_t) => { + const streams = [createReadableStream([["bom", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"bom",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should quote field with carriage return`, async (_t) => { + const streams = [createReadableStream([["a\rb", "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"a\rb",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should format Date as ISO string`, async (_t) => { + const d = new Date("2024-01-15T10:30:00.000Z"); + const streams = [createReadableStream([[d, "ok"]]), csvFormatStream()]; + strictEqual( + await streamToString(pipejoin(streams)), + "2024-01-15T10:30:00.000Z,ok\r\n", + ); +}); + +test(`${variant}: csvFormatStream should format number via String`, async (_t) => { + const streams = [createReadableStream([[42, 3.14]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), "42,3.14\r\n"); +}); + +test(`${variant}: csvFormatStream should format boolean via String in slow path`, async (_t) => { + const streams = [createReadableStream([[true, false]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), "true,false\r\n"); +}); + +test(`${variant}: csvFormatStream should quote negative number string`, async (_t) => { + const streams = [createReadableStream([[-42, "ok"]]), csvFormatStream()]; + strictEqual(await streamToString(pipejoin(streams)), '"-42",ok\r\n'); +}); + +test(`${variant}: csvFormatStream should format null and undefined as empty`, async (_t) => { + const streams = [ + createReadableStream([[null, undefined, "x"]]), + csvFormatStream(), + ]; + strictEqual(await streamToString(pipejoin(streams)), ",,x\r\n"); +}); + +test(`${variant}: csvFormatStream custom escape escapes escape and quote chars`, async (_t) => { + const streams = [ + createReadableStream([['a"b\\c', "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"a\\"b\\\\c",ok\r\n'); +}); + +test(`${variant}: csvFormatStream custom escape escapes only escape char when no quote`, async (_t) => { + const streams = [ + createReadableStream([["a\\b,c", "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + strictEqual(await streamToString(pipejoin(streams)), '"a\\\\b,c",ok\r\n'); +}); + +test(`${variant}: csvFormatStream batch boundary flushes at 64 rows`, async (_t) => { + const rows = []; + for (let i = 0; i < 70; i++) rows.push([String(i), "x"]); + const output = await streamToString( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + const lines = output.split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 70); + strictEqual(lines[0], "0,x"); + strictEqual(lines[69], "69,x"); +}); + +test(`${variant}: csvFormatStream custom newlineChar separator`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream([ + ["1", "2"], + ["3", "4"], + ]), + csvFormatStream({ newlineChar: "\n" }), + ]), + ); + strictEqual(output, "1,2\n3,4\n"); +}); + +test(`${variant}: csvFormatStream custom quoteChar`, async (_t) => { + const output = await streamToString( + pipejoin([ + createReadableStream([["a,b", "ok"]]), + csvFormatStream({ quoteChar: "'" }), + ]), + ); + strictEqual(output, "'a,b',ok\r\n"); +}); + +// --- Quoted-field post-quote newline dispatch: length 1 / 2 / >2 --- +test(`${variant}: csvQuotedParser quoted field then LF newline (length 1)`, (_t) => { + const result = csvQuotedParser('"a"\n"b"\n', { newlineChar: "\n" }, true); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then CRLF newline (length 2)`, (_t) => { + const result = csvQuotedParser( + '"a"\r\n"b"\r\n', + { newlineChar: "\r\n" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then lone CR is not a CRLF newline`, (_t) => { + const result = csvQuotedParser('"a"\rb,c\r\n', { newlineChar: "\r\n" }, true); + strictEqual(result.rows.length, 1); + strictEqual(result.rows[0][0], "a"); +}); + +test(`${variant}: csvQuotedParser quoted field then 3-char newline (length > 2)`, (_t) => { + const result = csvQuotedParser('"a"~|~"b"~|~', { newlineChar: "~|~" }, true); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then partial 3-char newline is garbage`, (_t) => { + const result = csvQuotedParser('"a"~|x~|~', { newlineChar: "~|~" }, true); + strictEqual(result.rows.length, 1); + strictEqual(result.rows[0][0], "a"); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then LF newline`, (_t) => { + const result = csvQuotedParser( + '"a"\n"b"\n', + { newlineChar: "\n", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then CRLF newline`, (_t) => { + const result = csvQuotedParser( + '"a"\r\n"b"\r\n', + { newlineChar: "\r\n", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then 3-char newline`, (_t) => { + const result = csvQuotedParser( + '"a"~|~"b"~|~', + { newlineChar: "~|~", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a"], ["b"]]); +}); + +test(`${variant}: csvQuotedParser custom escape quoted field then lone CR not CRLF`, (_t) => { + const result = csvQuotedParser( + '"a"\rb,c\r\n', + { newlineChar: "\r\n", escapeChar: "\\" }, + true, + ); + strictEqual(result.rows.length, 1); + strictEqual(result.rows[0][0], "a"); +}); + +// --- csvCoerceValuesStream: iso8601 regex shape --- +test(`${variant}: csvCoerceValuesStream should coerce date with multi-digit fractional seconds`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00.123Z" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00.123Z") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with timezone offset without colon`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00+0530" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00+0530") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with timezone offset with colon`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00+05:30" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15T10:30:00+05:30") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce date with space separator and seconds`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15 10:30:45" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-01-15 10:30:45") }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce plain date-only value`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-12-31" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: new Date("2024-12-31") }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep date with non-digit fractional as string`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "2024-01-15T10:30:00.abc" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: "2024-01-15T10:30:00.abc" }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce exponent forms`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "1e+10", b: "2e-05", c: "1.5E3" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: 1e10, b: 2e-5, c: 1500 }]); +}); + +test(`${variant}: csvCoerceValuesStream should keep malformed exponent as string`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "1e", b: "1e+", c: "1.2.3" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: "1e", b: "1e+", c: "1.2.3" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce trailing non-number text`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "12abc", d: "2024-01-15extra" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: "12abc", d: "2024-01-15extra" }]); +}); + +test(`${variant}: csvCoerceValuesStream should not coerce date missing leading anchor`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ d: "x2024-01-15" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ d: "x2024-01-15" }]); +}); + +test(`${variant}: csvCoerceValuesStream boolean first-char/length gating`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "trUE", b: "tree", c: "True", d: "truer" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: true, b: "tree", c: true, d: "truer" }]); +}); + +test(`${variant}: csvCoerceValuesStream should return null for empty string auto-coerce`, async (_t) => { + const output = await streamToArray( + pipejoin([createReadableStream([{ a: "" }]), csvCoerceValuesStream()]), + ); + deepStrictEqual(output, [{ a: null }]); +}); + +test(`${variant}: csvCoerceValuesStream should coerce single-digit zero and nine`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "0", b: "9" }]), + csvCoerceValuesStream(), + ]), + ); + deepStrictEqual(output, [{ a: 0, b: 9 }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit boolean lowercase compare`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "TRUE", b: "no", c: "true" }]), + csvCoerceValuesStream({ + columns: { a: "boolean", b: "boolean", c: "boolean" }, + }), + ]), + ); + deepStrictEqual(output, [{ a: true, b: false, c: true }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit boolean Boolean for non-string`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: 1, b: 0 }]), + csvCoerceValuesStream({ columns: { a: "boolean", b: "boolean" } }), + ]), + ); + deepStrictEqual(output, [{ a: true, b: false }]); +}); + +test(`${variant}: csvCoerceValuesStream explicit json parses array`, async (_t) => { + const output = await streamToArray( + pipejoin([ + createReadableStream([{ a: "[1,2,3]" }]), + csvCoerceValuesStream({ columns: { a: "json" } }), + ]), + ); + deepStrictEqual(output, [{ a: [1, 2, 3] }]); +}); + +// --- csvDetectDelimitersStream quote boundary precision --- +test(`${variant}: csvDetectDelimitersStream detects quote closing at end of text`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("x,y\nz,'w'"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote opening after CR-only newline`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a,b\r'c',d\r"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); + strictEqual(detect.result().value.newlineChar, "\r"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote closing before a delimiter`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("'a',b,c\n1,2,3\n"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote opening at text start`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("'a',b\n1,2\n"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects quote opening after newline`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a,b\n'c',d\n"), detect]); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream does NOT detect a quote that never closes at a field end`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([ + createReadableStream("name,note\n'x,it's fine\nhello,world\n"), + detect, + ]); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream sets escapeChar equal to detected quoteChar`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("'a','b'\n1,2\n"), detect]); + strictEqual(detect.result().value.escapeChar, "'"); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream picks tab over comma by precedence`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a\tb,c\n1\t2,3\n"), detect]); + strictEqual(detect.result().value.delimiterChar, "\t"); +}); + +test(`${variant}: csvDetectDelimitersStream picks pipe over semicolon and comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a|b;c,d\n1|2;3,4\n"), detect]); + strictEqual(detect.result().value.delimiterChar, "|"); +}); + +test(`${variant}: csvDetectDelimitersStream picks semicolon over comma`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a;b,c\n1;2,3\n"), detect]); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +test(`${variant}: csvDetectDelimitersStream defaults to double-quote when none present`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await pipeline([createReadableStream("a,b,c\n1,2,3\n"), detect]); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +// --- csvFormatStream: formatRowSlow null and empty-string branches --- +test(`${variant}: csvFormatStream should format null field alongside a number (formatRowSlow null branch)`, async (_t) => { + // null alongside a number forces isSimpleRow to return false (number is non-string), + // so formatRowSlow is called; the null field hits the val==null branch (parts[i]="") + const streams = [createReadableStream([[null, 42]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, ",42\r\n"); +}); + +test(`${variant}: csvFormatStream should format empty-string field alongside a quoting-required value (formatRowSlow empty-string branch)`, async (_t) => { + // A field needing quoting makes isSimpleRow return false, so formatRowSlow is called; + // the empty-string field hits the val.length===0 branch (parts[i]="") + const streams = [ + createReadableStream([["hello, world", ""]]), + csvFormatStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"hello, world",\r\n'); +}); + +// --- csvCoerceValuesStream: coerceToType date with invalid date string --- +test(`${variant}: csvCoerceValuesStream explicit date type should return original string for invalid date`, async (_t) => { + // coerceToType(val, "date") path: Number.isNaN(d.getTime()) === true → return val + const streams = [ + createReadableStream([{ val: "not-a-date" }]), + csvCoerceValuesStream({ columns: { val: "date" } }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ val: "not-a-date" }]); +}); + +// --- csvFormatStream: custom escape wrapQuote with no escape char in value --- +test(`${variant}: csvFormatStream custom escape should quote value with delimiter but no escape char`, async (_t) => { + // wrapQuote custom-escape path: value contains delimiter (needs quoting) + // but does NOT contain escapeChar → takes the "value" branch of the includes check + const streams = [ + createReadableStream([["a,b", "ok"]]), + csvFormatStream({ escapeChar: "\\" }), + ]; + const output = await streamToString(pipejoin(streams)); + // No backslash in "a,b", so no escapeChar escaping; only quoteChar wrapping + strictEqual(output, '"a,b",ok\r\n'); +}); + +// ===================================================================== +// Mutation-hardening tests (kill surviving Stryker mutants) +// ===================================================================== + +// --- csvQuotedParser: precise idx / numCols tracking --- +test(`${variant}: csvQuotedParser reports idx equal to number of rows (default path)`, (_t) => { + const result = csvQuotedParser("a,b\r\nc,d\r\ne,f\r\n"); + strictEqual(result.idx, 3); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvQuotedParser reports idx for quoted-field rows`, (_t) => { + // All rows have a leading quoted field so each row goes through the quoted + // branch idx++ (escapeIsQuote=true). idx must equal the row count. + const result = csvQuotedParser('"a",b\r\n"c",d\r\n"e",f\r\n'); + strictEqual(result.idx, 3); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ["e", "f"], + ]); +}); + +test(`${variant}: csvQuotedParser reports idx for custom-escape quoted rows`, (_t) => { + const result = csvQuotedParser( + '"a",b\r\n"c",d\r\n', + { escapeChar: "\\" }, + true, + ); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser flush short row truncates to fi (escapeIsQuote)`, (_t) => { + // numCols=3 from first row; second row quoted single field on flush -> length 1 + const result = csvQuotedParser('a,b,c\r\n"d"', {}, true); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["d"]); + strictEqual(result.rows[1].length, 1); +}); + +test(`${variant}: csvQuotedParser flush short row truncates to fi (custom escape)`, (_t) => { + const result = csvQuotedParser('a,b,c\r\n"d"', { escapeChar: "\\" }, true); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["d"]); + strictEqual(result.rows[1].length, 1); +}); + +test(`${variant}: csvQuotedParser unterminated quote short row truncates to fi (escapeIsQuote)`, (_t) => { + // numCols=3; an unterminated quoted field on flush must produce exactly 1 field + const result = csvQuotedParser('a,b,c\r\n"unterminated', {}, true); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["unterminated"]); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.errors.UnterminatedQuote.idx, [1]); +}); + +test(`${variant}: csvQuotedParser unterminated quote short row truncates to fi (custom escape)`, (_t) => { + const result = csvQuotedParser( + 'a,b,c\r\n"unterminated', + { escapeChar: "\\" }, + true, + ); + strictEqual(result.numCols, 3); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows[1], ["unterminated"]); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.errors.UnterminatedQuote.idx, [1]); +}); + +test(`${variant}: csvQuotedParser unterminated quote establishes numCols when first (escapeIsQuote)`, (_t) => { + // First (and only) row is an unterminated quote on flush: numCols becomes fi (1) + const result = csvQuotedParser('"unterminated', {}, true); + strictEqual(result.numCols, 1); + strictEqual(result.idx, 1); + deepStrictEqual(result.rows, [["unterminated"]]); +}); + +test(`${variant}: csvQuotedParser unterminated quote establishes numCols when first (custom escape)`, (_t) => { + const result = csvQuotedParser('"unterminated', { escapeChar: "\\" }, true); + strictEqual(result.numCols, 1); + strictEqual(result.idx, 1); + deepStrictEqual(result.rows, [["unterminated"]]); +}); + +test(`${variant}: csvQuotedParser unterminated quote not flushing returns rowStart tail (escapeIsQuote)`, (_t) => { + // not flushing: ctx.tail must be text.substring(rowStart), preserving the whole + // unterminated row including its quote. + const result = csvQuotedParser('a,b\r\n"unterm', {}, false); + strictEqual(result.tail, '"unterm'); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser unterminated quote not flushing returns rowStart tail (custom escape)`, (_t) => { + const result = csvQuotedParser( + 'a,b\r\nx,"unterm', + { escapeChar: "\\" }, + false, + ); + strictEqual(result.tail, 'x,"unterm'); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser quoted first field establishes numCols (escapeIsQuote)`, (_t) => { + // First row begins with a quoted field then newline path establishes numCols. + const result = csvQuotedParser('"a","b"\r\nc,d\r\n'); + strictEqual(result.numCols, 2); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser quoted first field establishes numCols (custom escape)`, (_t) => { + const result = csvQuotedParser('"a","b"\r\nc,d\r\n', { escapeChar: "\\" }); + strictEqual(result.numCols, 2); + strictEqual(result.idx, 2); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvQuotedParser quoted-field row after established numCols truncates surplus (escapeIsQuote)`, (_t) => { + // numCols=2; a later quoted-first row with 3 fields must be truncated? No — + // surplus is kept by indexOf path; assert exact when row has fewer fields. + const result = csvQuotedParser('"a","b"\r\n"c"\r\n'); + strictEqual(result.rows[1].length, 1); + deepStrictEqual(result.rows[1], ["c"]); + strictEqual(result.idx, 2); +}); + +// --- csvQuotedParser: garbage-after-closing-quote keeps extra fields --- +test(`${variant}: csvQuotedParser keeps garbage after closing quote as separate field (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('"a"x,b\r\n'); + deepStrictEqual(result.rows, [["a", "x", "b"]]); +}); + +test(`${variant}: csvQuotedParser keeps garbage after closing quote as separate field (custom escape)`, (_t) => { + const result = csvQuotedParser('"a"x,b\r\n', { escapeChar: "\\" }); + deepStrictEqual(result.rows, [["a", "x", "b"]]); +}); + +// --- csvQuotedParser: quoted field at exact end of input (pos >= len) --- +test(`${variant}: csvQuotedParser quoted field at exact end of input keeps field (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('a,"b"', {}, true); + deepStrictEqual(result.rows, [["a", "b"]]); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser quoted field at exact end of input keeps field (custom escape)`, (_t) => { + const result = csvQuotedParser('a,"b"', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a", "b"]]); + strictEqual(result.idx, 1); +}); + +// --- csvQuotedParser: delimiter immediately after closing quote (lastWasDelimiter) --- +test(`${variant}: csvQuotedParser quoted field then delimiter then trailing flush field (escapeIsQuote)`, (_t) => { + // "a", then delimiter sets lastWasDelimiter=true; flush must add a trailing "". + const result = csvQuotedParser('"a",', {}, true); + deepStrictEqual(result.rows, [["a", ""]]); + strictEqual(result.idx, 1); +}); + +test(`${variant}: csvQuotedParser quoted field then delimiter then trailing flush field (custom escape)`, (_t) => { + const result = csvQuotedParser('"a",', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a", ""]]); + strictEqual(result.idx, 1); +}); + +// --- csvQuotedParser: newline-length boundary (CRLF needs second char) --- +test(`${variant}: csvQuotedParser quoted field then CR without LF is garbage not newline (escapeIsQuote)`, (_t) => { + // nc===CR but pos+1 is end -> not a CRLF; CR treated as garbage, kept inline. + const result = csvQuotedParser('"a"\r', { newlineChar: "\r\n" }, true); + // "a" then lone CR (garbage after quote) at end of input + deepStrictEqual(result.rows, [["a", "\r"]]); +}); + +test(`${variant}: csvQuotedParser quoted field then CR without LF is garbage not newline (custom escape)`, (_t) => { + const result = csvQuotedParser( + '"a"\r', + { newlineChar: "\r\n", escapeChar: "\\" }, + true, + ); + deepStrictEqual(result.rows, [["a", "\r"]]); +}); + +// --- csvParseInline numCols>0 fast path: idx, malformed, surplus --- +test(`${variant}: csvQuotedParser fast unquoted path reports idx for many rows`, (_t) => { + // numCols established as 2 by first row, then fast path handles the rest. + const result = csvQuotedParser("a,b\r\n1,2\r\n3,4\r\n5,6\r\n7,8\r\n"); + strictEqual(result.idx, 5); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows[4], ["7", "8"]); +}); + +test(`${variant}: csvQuotedParser fast path malformed too-few columns split fallback`, (_t) => { + // numCols=3; a row with only 2 fields uses the split fallback, keeping 2 fields. + const result = csvQuotedParser("a,b,c\r\n1,2\r\n"); + deepStrictEqual(result.rows[1], ["1", "2"]); + strictEqual(result.rows[1].length, 2); +}); + +test(`${variant}: csvQuotedParser fast path surplus columns kept separate via split`, (_t) => { + // numCols=2; a row with 4 fields must keep all 4 (split fallback), not merge. + const result = csvQuotedParser("a,b\r\n1,2,3,4\r\n"); + deepStrictEqual(result.rows[1], ["1", "2", "3", "4"]); + strictEqual(result.rows[1].length, 4); +}); + +test(`${variant}: csvQuotedParser fast path exact column count keeps fields`, (_t) => { + const result = csvQuotedParser("a,b,c\r\n1,2,3\r\n4,5,6\r\n"); + deepStrictEqual(result.rows[1], ["1", "2", "3"]); + deepStrictEqual(result.rows[2], ["4", "5", "6"]); + strictEqual(result.idx, 3); +}); + +test(`${variant}: csvQuotedParser fast path partial last row without newline returns tail`, (_t) => { + // numCols=2; trailing partial row (no newline) must be returned as tail. + const result = csvQuotedParser("a,b\r\n1,2\r\n3,4", {}, false); + strictEqual(result.tail, "3,4"); + strictEqual(result.idx, 2); +}); + +// --- csvUnquotedParser: idx / numCols --- +test(`${variant}: csvUnquotedParser reports idx and numCols`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2\r\n3,4\r\n"); + strictEqual(result.idx, 3); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser flush increments idx for trailing row`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2", {}, true); + strictEqual(result.idx, 2); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser preserves explicit idx and numCols options`, (_t) => { + const result = csvUnquotedParser("1,2\r\n", { idx: 5, numCols: 2 }); + strictEqual(result.idx, 6); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser no trailing row when input ends with newline`, (_t) => { + const result = csvUnquotedParser("a,b\r\n", {}, true); + strictEqual(result.idx, 1); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +// --- csvParseStream: idx-sensitive error reporting through the stream --- +test(`${variant}: csvParseStream reports unterminated quote idx after several rows`, async (_t) => { + const streams = [ + createReadableStream('a,b\r\nc,d\r\ne,f\r\n"unterminated'), + csvParseStream(), + ]; + const result = await pipeline(streams); + // 3 complete rows (idx 0,1,2) then unterminated at idx 3 + deepStrictEqual(result.csvErrors.UnterminatedQuote.idx, [3]); +}); + +test(`${variant}: csvParseStream reports unterminated quote idx after several rows (custom escape)`, async (_t) => { + const streams = [ + createReadableStream('a,b\r\nc,d\r\ne,f\r\nx,"unterminated'), + csvParseStream({ escapeChar: "\\" }), + ]; + const result = await pipeline(streams); + deepStrictEqual(result.csvErrors.UnterminatedQuote.idx, [3]); +}); + +// --- findRowEnd via csvDetectHeaderStream --- +test(`${variant}: csvDetectHeaderStream handles quoted header with escaped quote and embedded newline`, async (_t) => { + // Header field is quoted, contains an escaped "" and a newline; findRowEnd must + // skip the in-quote newline and the escaped-quote pair, terminating at the real + // row-end newline so the header parses as two columns. + const hdr = csvDetectHeaderStream(); + const streams = [ + createReadableStream('"a""b\r\nc",second\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ['a"b\r\nc', "second"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom-escape header skips escaped quote in quoted field`, async (_t) => { + // escapeChar='\\': inside the quoted header field, \" is an escaped quote, so the + // field stays open past it; findRowEnd's odd-run lookback must keep scanning. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const streams = [ + createReadableStream('"a\\"b,c",second\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ['a"b,c', "second"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom-escape even run closes quote in findRowEnd`, async (_t) => { + // "\\\\" is an even run of escape chars => the quote IS a real closing quote, + // so the delimiter after it splits the header field. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const streams = [ + createReadableStream('"a\\\\",b\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ["a\\", "b"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream delimiter inside header advances fieldStart`, async (_t) => { + // Ensures findRowEnd's delimiter branch (pos += delimiterLength; fieldStart=pos) + // is exercised: a quoted field appears after a delimiter so it must still be + // recognized as a field-start quote. + const hdr = csvDetectHeaderStream(); + const streams = [ + createReadableStream('x,"q,uoted"\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ["x", "q,uoted"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream multi-char delimiter inside findRowEnd`, async (_t) => { + const hdr = csvDetectHeaderStream({ delimiterChar: "::" }); + const streams = [ + createReadableStream('"a::b"::c\r\n1::2\r\n'), + hdr, + csvParseStream({ delimiterChar: "::" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(hdr.result().value.header, ["a::b", "c"]); + deepStrictEqual(output, [["1", "2"]]); +}); + +// --- csvDetectDelimitersStream: chunkSize threshold and buffer reset --- +test(`${variant}: csvDetectDelimitersStream waits until buffer reaches chunkSize`, async (_t) => { + // chunkSize 8: first chunk (len 6) must NOT trigger detection; second chunk does. + const detect = csvDetectDelimitersStream({ chunkSize: 8 }); + const streams = [createReadableStream(["a;b\r\n", "c;d\r\n"]), detect]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + strictEqual(detect.result().value.delimiterChar, ";"); + strictEqual(output, "a;b\r\nc;d\r\n"); +}); + +test(`${variant}: csvDetectDelimitersStream emits buffered content exactly once`, async (_t) => { + // Make sure buffer is cleared after detection so content is not duplicated. + const detect = csvDetectDelimitersStream({ chunkSize: 4 }); + const streams = [ + createReadableStream(["a|b\r\n", "rest1\r\n", "rest2\r\n"]), + detect, + ]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + strictEqual(detect.result().value.delimiterChar, "|"); + strictEqual(output, "a|b\r\nrest1\r\nrest2\r\n"); +}); + +test(`${variant}: csvDetectDelimitersStream detect returns true sets detected so later chunks pass through`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 4 }); + const streams = [createReadableStream(["a,b\r\n", "later\r\n"]), detect]; + const stream = pipejoin(streams); + const output = await streamToString(stream); + strictEqual(output, "a,b\r\nlater\r\n"); +}); + +// --- unescapeCustom boundary (i + 1 < len) via custom-escape parse --- +test(`${variant}: csvParseStream custom-escape unescapeCustom handles escaped escape and escaped quote`, async (_t) => { + // Content (between the outer quotes) is: \ \ x \ " y + // - "\\\\" is an escaped escape -> single backslash + // - "\\\"" is an escaped quote -> literal quote (does not close the field) + // The final unescaped quote closes the field. unescapeCustom must collapse both. + const streams = [ + createReadableStream('"\\\\x\\"y",z\r\n'), + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [['\\x"y', "z"]]); +}); + +test(`${variant}: csvParseStream custom-escape escaped quote inside field unescaped once`, async (_t) => { + const streams = [ + createReadableStream('"a\\"b\\"c",d\r\n'), + csvParseStream({ escapeChar: "\\" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [['a"b"c', "d"]]); +}); + +// --- csvParseStream fast-path: text.indexOf(quoteChar) === -1 guard --- +test(`${variant}: csvParseStream switches off fast path when a quote appears in later row`, async (_t) => { + // First row establishes numCols (no quotes); a later row contains a quoted field + // with an embedded delimiter, which must NOT be split. + const streams = [ + createReadableStream('a,b\r\n"x,y",z\r\n'), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["x,y", "z"], + ]); +}); + +// --- csvParseStream: first-row detection when numCols===0 and a quote exists later in row --- +test(`${variant}: csvParseStream first row with quote does not use split detection`, async (_t) => { + // First row itself contains a quoted field with an embedded delimiter. + const streams = [ + createReadableStream('"a,b",c\r\nd,e\r\n'), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a,b", "c"], + ["d", "e"], + ]); + deepStrictEqual(streams[1].result().value, {}); +}); + +// --- csvParseStream: regular indexOf path, nextNl < nextDelim and trailing-delim --- +test(`${variant}: csvParseStream quoted first field then plain rows exercise regular path`, async (_t) => { + // A quoted first field forces fi!==0 entry so the regular indexOf path runs for + // the remaining fields/rows. + const streams = [ + createReadableStream('"a",b,c\r\nd,e,f\r\n'), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b", "c"], + ["d", "e", "f"], + ]); +}); + +// --- csvSteamifyParser resolveOptions: delimiterCharSingle / newlineCharSingle --- +test(`${variant}: csvParseStream multi-char delimiter is not treated as single`, async (_t) => { + // delimiterCharSingle=false path: post-quote dispatch must use startsWith. + const streams = [ + createReadableStream('"a"::"b"::c\r\n'), + csvParseStream({ delimiterChar: "::" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [["a", "b", "c"]]); +}); + +test(`${variant}: csvParseStream 2-char newline second-char check after quoted field`, async (_t) => { + // newlineCharSingle is the 2nd char of a 2-char newline; a quoted field then the + // 2-char newline must split rows, and a partial first char must not. + const streams = [ + createReadableStream('"a",b\r\n"c",d\r\n'), + csvParseStream({ newlineChar: "\r\n" }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvParseStream: typeof chunk string vs Buffer --- +test(`${variant}: csvParseStream coerces Buffer chunk via toString`, async (_t) => { + const streams = [ + createReadableStream([Buffer.from("a,b\r\nc,d\r\n")]), + csvParseStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvParseStream: buffer concat across chunks (buffer.length>0 ? buffer+str) --- +test(`${variant}: csvParseStream concatenates buffered tail with next chunk`, async (_t) => { + // A row split across two chunks at chunkSize=1 so the second chunk must be + // prefixed with the buffered partial row. + const streams = [ + createReadableStream(["a,b\r\n1,", "2\r\n"]), + csvParseStream({ chunkSize: 1 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +// --- csvParseStream: safety limit boundary (text.length > fieldMaxSize*2) --- +test(`${variant}: csvParseStream accepts text at exactly fieldMaxSize*2`, async (_t) => { + // length === fieldMaxSize*2 must NOT throw ( strict greater-than ). + const fieldMaxSize = 8; + const text = "x".repeat(fieldMaxSize * 2); // 16, no newline -> one field on flush + const streams = [ + createReadableStream(text), + csvParseStream({ fieldMaxSize }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [[text]]); +}); + +// --- csvParseStream ready-path: inputLen < chunkSize and single vs join --- +test(`${variant}: csvParseStream single chunk equal to chunkSize processes without join`, async (_t) => { + const row = "a,b\r\n"; // length 5 + const streams = [ + createReadableStream([row]), + csvParseStream({ chunkSize: 5 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [["a", "b"]]); +}); + +test(`${variant}: csvParseStream multiple chunks joined when crossing chunkSize`, async (_t) => { + const streams = [ + createReadableStream(["a,", "b\r\n", "c,d\r\n"]), + csvParseStream({ chunkSize: 3 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- csvRemoveEmptyRowsStream: zero-length row returns true immediately --- +test(`${variant}: csvRemoveEmptyRowsStream treats zero-length array as empty (l===0 branch)`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const streams = [createReadableStream([[], ["a"]]), filter]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [["a"]]); + deepStrictEqual(filter.result().value.EmptyRow.idx, [0]); +}); + +// --- csvArrayToObject: defineProperty descriptor flags --- +test(`${variant}: csvArrayToObject __proto__ column is writable, enumerable, configurable`, async (_t) => { + const streams = [ + createReadableStream([["v1", "v2"]]), + csvArrayToObject({ headers: ["__proto__", "b"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + const obj = output[0]; + const desc = Object.getOwnPropertyDescriptor(obj, "__proto__"); + ok(desc, "own __proto__ descriptor exists"); + strictEqual(desc.value, "v1"); + strictEqual(desc.enumerable, true); + strictEqual(desc.writable, true); + strictEqual(desc.configurable, true); + // enumerable means it shows up in keys + deepStrictEqual(Object.keys(obj), ["__proto__", "b"]); +}); + +test(`${variant}: csvArrayToObject constructor column stored as own data property`, async (_t) => { + const streams = [ + createReadableStream([["v1", "v2"]]), + csvArrayToObject({ headers: ["constructor", "b"] }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + const obj = output[0]; + const desc = Object.getOwnPropertyDescriptor(obj, "constructor"); + ok(desc); + strictEqual(desc.value, "v1"); + strictEqual(desc.enumerable, true); +}); + +// --- csvCoerceValuesStream: autoCoerce boolean gating (len & first-char) --- +test(`${variant}: csvCoerceValuesStream autoCoerce true requires length 4 and t/T`, async (_t) => { + const streams = [ + createReadableStream([{ a: "true", b: "True", c: "trueX", d: "rue" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // "true"->true, "True"->true, "trueX" (len 5, not false) stays string, "rue" stays + strictEqual(output[0].a, true); + strictEqual(output[0].b, true); + strictEqual(output[0].c, "trueX"); + strictEqual(output[0].d, "rue"); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce false requires length 5 and f/F`, async (_t) => { + const streams = [ + createReadableStream([{ a: "false", b: "False", c: "fALSE", d: "falsey" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, false); + strictEqual(output[0].b, false); + strictEqual(output[0].c, false); + strictEqual(output[0].d, "falsey"); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce digit and minus start numeric path`, async (_t) => { + const streams = [ + createReadableStream([{ a: "42", b: "-7", c: "9", d: "0" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, 42); + strictEqual(output[0].b, -7); + strictEqual(output[0].c, 9); + strictEqual(output[0].d, 0); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce JSON only for braces/brackets`, async (_t) => { + const streams = [ + createReadableStream([{ a: '{"x":1}', b: "[1,2]", c: "plain" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output[0].a, { x: 1 }); + deepStrictEqual(output[0].b, [1, 2]); + strictEqual(output[0].c, "plain"); +}); + +test(`${variant}: csvCoerceValuesStream autoCoerce invalid JSON returns the original string (catch returns val)`, async (_t) => { + const streams = [ + createReadableStream([{ a: "{not json", b: "[oops" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, "{not json"); + strictEqual(output[0].b, "[oops"); +}); + +test(`${variant}: csvCoerceValuesStream coerceToType invalid json returns original string (catch returns val)`, async (_t) => { + const streams = [ + createReadableStream([{ a: "{not json" }]), + csvCoerceValuesStream({ columns: { a: "json" } }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + strictEqual(output[0].a, "{not json"); +}); + +// --- autoCoerce iso8601 anchoring (^ and $) --- +test(`${variant}: csvCoerceValuesStream iso date requires full anchor (trailing junk stays string)`, async (_t) => { + const streams = [ + createReadableStream([{ a: "2020-01-02xyz", b: "2020-01-02" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + // trailing junk -> regex (anchored) fails, stays string + strictEqual(output[0].a, "2020-01-02xyz"); + ok(output[0].b instanceof Date); +}); + +test(`${variant}: csvCoerceValuesStream iso date with seconds-and-fraction group present`, async (_t) => { + const streams = [ + createReadableStream([{ a: "2020-01-02T03:04:05.678Z" }]), + csvCoerceValuesStream(), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + ok(output[0].a instanceof Date); + strictEqual( + output[0].a.getTime(), + new Date("2020-01-02T03:04:05.678Z").getTime(), + ); +}); + +// --- csvFormatStream: delimiterSingle vs multi, scanNeedsQuote loops --- +test(`${variant}: csvFormatStream single-char delimiter quotes interior delimiter only on actual delimiter`, async (_t) => { + const streams = [ + createReadableStream([["a;b", "c,d"]]), + csvFormatStream(), // delimiter "," + ]; + const output = await streamToString(pipejoin(streams)); + // "a;b" has no comma -> not quoted; "c,d" has comma -> quoted + strictEqual(output, 'a;b,"c,d"\r\n'); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes value containing the delimiter substring`, async (_t) => { + const streams = [ + createReadableStream([["a::b", "plain"]]), + csvFormatStream({ delimiterChar: "::" }), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, '"a::b"::plain\r\n'); +}); + +// --- csvFormatStream: batch boundary at exactly 64 --- +test(`${variant}: csvFormatStream emits when batch reaches exactly 64 rows`, async (_t) => { + const rows = []; + for (let i = 0; i < 64; i++) rows.push([`r${i}`, "x"]); + // Collect each enqueued chunk separately + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + // With exactly 64 rows, the transform emits one chunk of 64 rows and flush emits nothing. + strictEqual(chunks.length, 1); + const lines = chunks[0].split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 64); +}); + +test(`${variant}: csvFormatStream below 64 rows emits only on flush`, async (_t) => { + const rows = []; + for (let i = 0; i < 10; i++) rows.push([`r${i}`, "x"]); + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + strictEqual(chunks.length, 1); + const lines = chunks[0].split("\r\n").filter((l) => l.length > 0); + strictEqual(lines.length, 10); +}); + +// --- csvFormatStream: isSimpleRow non-string / needs-quote detection --- +test(`${variant}: csvFormatStream isSimpleRow rejects rows with a quoting-needed string`, async (_t) => { + // A value needing a quote forces the slow path; assert correct quoting. + const streams = [ + createReadableStream([["plain", "needs\nquote"]]), + csvFormatStream(), + ]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, 'plain,"needs\nquote"\r\n'); +}); + +test(`${variant}: csvFormatStream simple row of plain strings uses join fast path`, async (_t) => { + const streams = [createReadableStream([["a", "b", "c"]]), csvFormatStream()]; + const output = await streamToString(pipejoin(streams)); + strictEqual(output, "a,b,c\r\n"); +}); + +// --- csvParseStream streamOptions default highWaterMark preserved --- +test(`${variant}: csvParseStream respects provided streamOptions override`, async (_t) => { + // Passing streamOptions should not break parsing (ObjectLiteral mutant guard). + const streams = [ + createReadableStream("a,b\r\nc,d\r\n"), + csvParseStream({}, { highWaterMark: 1 }), + ]; + const stream = pipejoin(streams); + const output = await streamToArray(stream); + deepStrictEqual(output, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// ===================================================================== +// Mutation-hardening tests, batch 2 +// ===================================================================== + +// --- findRowEnd: a quote is only an opener at field-start --- +test(`${variant}: csvDetectHeaderStream mid-field quote is not a quote opener`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream('a"b,c\r\nd,e\r\n'), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ['a"b', "c"]); + deepStrictEqual(out, [["d", "e"]]); +}); + +test(`${variant}: csvDetectHeaderStream quote opening right after a delimiter is honored`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('a,"x,y"\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "x,y"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream keeps newline inside quoted header field`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('"line1\r\nline2",b\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["line1\r\nline2", "b"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- findRowEnd: custom-escape parity around a quoted newline --- +test(`${variant}: csvDetectHeaderStream custom escape keeps escaped quote then newline inside the field`, async (_t) => { + // "x\"y\r\nz" — the \" is an escaped quote (odd run of escapeChar), so the + // field stays open and the \r\n belongs to the header field, not the row end. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"x\\"y\r\nz",b\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['x"y\r\nz', "b"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom escape even run closes the quote before the newline`, async (_t) => { + // "a\\" — \\ is an escaped escape (even run), so the quote closes; the row + // ends at the very next newline. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"a\\\\",b\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a\\", "b"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream custom escape backslash before quote keeps newline in field`, async (_t) => { + // A lone escaped quote right before the newline must keep the field open so + // the embedded \r\n does not terminate the header row. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"a\\"\r\nb",c\r\nd,e\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['a"\r\nb', "c"]); + deepStrictEqual(out, [["d", "e"]]); +}); + +// --- csvDetectDelimitersStream: isFieldStart / isFieldEnd --- +test(`${variant}: csvDetectDelimitersStream detects quote closing right before CR`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("'a'\r'b'\r"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream does not treat unopened quote as quoteChar`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("ab'cd,ef\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream buffers until a newline appears`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 3 }); + const out = await streamToString( + pipejoin([createReadableStream(["abcd", "e;f\r\n"]), detect]), + ); + strictEqual(out, "abcde;f\r\n"); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +// --- csvParseStream: 3-char newline dispatch after quoted field --- +test(`${variant}: csvParseStream quoted field then 3-char newline splits rows (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b###"c",d'), + csvParseStream({ newlineChar: "###" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then partial 3-char newline is garbage (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a"##b'), + csvParseStream({ newlineChar: "###" }), + ]), + ); + deepStrictEqual(out, [["a", "##b"]]); +}); + +test(`${variant}: csvParseStream quoted field then 3-char newline splits rows (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b###"c",d'), + csvParseStream({ newlineChar: "###", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then partial 3-char newline is garbage (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a"##b'), + csvParseStream({ newlineChar: "###", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [["a", "##b"]]); +}); + +// --- CRLF after quoted field requires both chars --- +test(`${variant}: csvParseStream quoted field then CRLF splits (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\r\n"c",d\r\n'), + csvParseStream({ newlineChar: "\r\n" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then CRLF splits (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\r\n"c",d\r\n'), + csvParseStream({ newlineChar: "\r\n", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- LF-only (length 1) newline after quoted field --- +test(`${variant}: csvParseStream quoted field then LF newline length 1 (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\n"c",d\n'), + csvParseStream({ newlineChar: "\n" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +test(`${variant}: csvParseStream quoted field then LF newline length 1 (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream('"a",b\n"c",d\n'), + csvParseStream({ newlineChar: "\n", escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- field exactly fieldMaxSize allowed (strict >) --- +test(`${variant}: csvParseStream allows quoted field exactly fieldMaxSize (escapeIsQuote)`, async (_t) => { + const val = "x".repeat(10); + const out = await streamToArray( + pipejoin([ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10 }), + ]), + ); + deepStrictEqual(out, [[val, "y"]]); +}); + +test(`${variant}: csvParseStream allows custom-escape quoted field exactly fieldMaxSize`, async (_t) => { + const val = "x".repeat(10); + const out = await streamToArray( + pipejoin([ + createReadableStream(`"${val}",y\r\n`), + csvParseStream({ fieldMaxSize: 10, escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [[val, "y"]]); +}); + +// --- unterminated quote error message text --- +test(`${variant}: csvParseStream unterminated quote message (escapeIsQuote)`, async (_t) => { + const parse = csvParseStream(); + await pipeline([createReadableStream('"unterminated'), parse]); + strictEqual( + parse.result().value.UnterminatedQuote.message, + "Unterminated quoted field", + ); +}); + +test(`${variant}: csvParseStream unterminated quote message (custom escape)`, async (_t) => { + const parse = csvParseStream({ escapeChar: "\\" }); + await pipeline([createReadableStream('x,"unterminated'), parse]); + strictEqual( + parse.result().value.UnterminatedQuote.message, + "Unterminated quoted field", + ); +}); + +// --- not-flushing unterminated quote kept in tail across chunks --- +test(`${variant}: csvParseStream buffers unterminated quoted field across chunks (escapeIsQuote)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(['"hel', 'lo",x\r\n']), + csvParseStream({ chunkSize: 1 }), + ]), + ); + deepStrictEqual(out, [["hello", "x"]]); +}); + +test(`${variant}: csvParseStream buffers unterminated quoted field across chunks (custom escape)`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream(['"hel', 'lo",x\r\n']), + csvParseStream({ chunkSize: 1, escapeChar: "\\" }), + ]), + ); + deepStrictEqual(out, [["hello", "x"]]); +}); + +// --- csvUnquotedParser numCols from first row --- +test(`${variant}: csvUnquotedParser numCols comes from first row not later rows`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3\r\n"); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser flush keeps first-row numCols`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3", {}, true); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows[1], ["1", "2", "3"]); +}); + +// --- csvFormatStream scanNeedsQuote triggers --- +test(`${variant}: csvFormatStream quotes a field that only ends with a space`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["ab ", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"ab ",cd\r\n'); +}); + +test(`${variant}: csvFormatStream does not quote a field with only an interior space`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a b", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, "a b,cd\r\n"); +}); + +test(`${variant}: csvFormatStream quotes a field containing the quote char`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([['a"b', "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"a""b",cd\r\n'); +}); + +test(`${variant}: csvFormatStream quotes a field containing a carriage return`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a\rb", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"a\rb",cd\r\n'); +}); + +test(`${variant}: csvFormatStream quotes a field containing a line feed`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a\nb", "cd"]]), csvFormatStream()]), + ); + strictEqual(out, '"a\nb",cd\r\n'); +}); + +test(`${variant}: csvFormatStream does not add a trailing empty field`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["a", "b", "c"]]), csvFormatStream()]), + ); + strictEqual(out, "a,b,c\r\n"); +}); + +test(`${variant}: csvFormatStream multi-char delimiter does not add trailing empty field`, async (_t) => { + const out = await streamToString( + pipejoin([ + createReadableStream([["a", "b", "c"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(out, "a::b::c\r\n"); +}); + +test(`${variant}: csvFormatStream multi-char delimiter quotes value containing the delimiter`, async (_t) => { + const out = await streamToString( + pipejoin([ + createReadableStream([["a::b", "x"]]), + csvFormatStream({ delimiterChar: "::" }), + ]), + ); + strictEqual(out, '"a::b"::x\r\n'); +}); + +// --- batch flush boundary (>= 64) --- +test(`${variant}: csvFormatStream does not flush at 63 rows`, async (_t) => { + const rows = []; + for (let i = 0; i < 63; i++) rows.push([String(i), "x"]); + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + strictEqual(chunks.length, 1); + strictEqual(chunks[0].split("\r\n").filter((l) => l.length > 0).length, 63); +}); + +test(`${variant}: csvFormatStream flushes at exactly 64 rows then a separate flush for extras`, async (_t) => { + const rows = []; + for (let i = 0; i < 65; i++) rows.push([String(i), "x"]); + const chunks = await streamToArray( + pipejoin([createReadableStream(rows), csvFormatStream()]), + ); + strictEqual(chunks.length, 2); + strictEqual(chunks[0].split("\r\n").filter((l) => l.length > 0).length, 64); + strictEqual(chunks[1].split("\r\n").filter((l) => l.length > 0).length, 1); +}); + +// --- null/empty/number/Date coercion in formatRow --- +test(`${variant}: csvFormatStream formats null as empty field next to a quoted value`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([[null, "a,b"]]), csvFormatStream()]), + ); + strictEqual(out, ',"a,b"\r\n'); +}); + +test(`${variant}: csvFormatStream formats empty string as empty field next to a quoted value`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([["", "a,b"]]), csvFormatStream()]), + ); + strictEqual(out, ',"a,b"\r\n'); +}); + +test(`${variant}: csvFormatStream coerces a number field via String`, async (_t) => { + const out = await streamToString( + pipejoin([createReadableStream([[42, "x"]]), csvFormatStream()]), + ); + strictEqual(out, "42,x\r\n"); +}); + +test(`${variant}: csvFormatStream coerces a Date field via toISOString`, async (_t) => { + const d = new Date("2020-01-02T03:04:05.000Z"); + const out = await streamToString( + pipejoin([createReadableStream([[d, "x"]]), csvFormatStream()]), + ); + strictEqual(out, "2020-01-02T03:04:05.000Z,x\r\n"); +}); + +// --- iso8601 regex anchors and seconds group --- +test(`${variant}: csvCoerceValuesStream iso date needs the ^ anchor`, async (_t) => { + // A leading space makes the anchored regex fail (stays a string), but a + // non-anchored regex would match the embedded date and `new Date(" 2020-01-02")` + // IS valid — so only the ^ anchor keeps this a string. + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: " 2020-01-02" }]), + csvCoerceValuesStream(), + ]), + ); + strictEqual(out[0].a, " 2020-01-02"); +}); + +test(`${variant}: csvCoerceValuesStream iso date needs the $ anchor`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: "2020-01-02junk" }]), + csvCoerceValuesStream(), + ]), + ); + strictEqual(out[0].a, "2020-01-02junk"); +}); + +test(`${variant}: csvCoerceValuesStream iso date with time but no seconds is still a Date`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: "2020-01-02T03:04" }]), + csvCoerceValuesStream(), + ]), + ); + ok(out[0].a instanceof Date); + strictEqual(out[0].a.getTime(), new Date("2020-01-02T03:04").getTime()); +}); + +test(`${variant}: csvCoerceValuesStream iso date with seconds is a Date`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([{ a: "2020-01-02T03:04:05" }]), + csvCoerceValuesStream(), + ]), + ); + ok(out[0].a instanceof Date); +}); + +// --- csvRemoveEmptyRowsStream l === 0 short-circuit --- +test(`${variant}: csvRemoveEmptyRowsStream drops a zero-length row`, async (_t) => { + const filter = csvRemoveEmptyRowsStream(); + const out = await streamToArray( + pipejoin([createReadableStream([[], ["keep"]]), filter]), + ); + deepStrictEqual(out, [["keep"]]); + deepStrictEqual(filter.result().value.EmptyRow.idx, [0]); +}); + +// --- csvArrayToObject constructor / normal key branches --- +test(`${variant}: csvArrayToObject stores a constructor column as an own data property`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([["v"]]), + csvArrayToObject({ headers: ["constructor"] }), + ]), + ); + const obj = out[0]; + strictEqual(Object.getOwnPropertyDescriptor(obj, "constructor").value, "v"); + deepStrictEqual(Object.keys(obj), ["constructor"]); +}); + +test(`${variant}: csvArrayToObject assigns a normal key directly`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([["v1", "v2"]]), + csvArrayToObject({ headers: ["a", "b"] }), + ]), + ); + deepStrictEqual(out[0], { a: "v1", b: "v2" }); +}); + +// ===================================================================== +// Mutation-hardening tests, batch 3 +// ===================================================================== + +// --- numCols reflects the FIRST row's field count, not later rows --- +test(`${variant}: csvQuotedParser numCols is first row count even when later rows differ`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2,3\r\n"); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvQuotedParser numCols first row via quoted first field`, (_t) => { + const result = csvQuotedParser('"a",b\r\n1,2,3\r\n'); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser numCols is first row count even when later rows differ`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3\r\n"); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvUnquotedParser flush numCols is first row count`, (_t) => { + const result = csvUnquotedParser("a,b\r\n1,2,3", {}, true); + strictEqual(result.numCols, 2); +}); + +// --- autoCoerce JSON gate only applies to '{' or '[' --- +test(`${variant}: csvCoerceValuesStream does not JSON-parse the bare word null`, async (_t) => { + // "null" is valid JSON but must NOT be parsed (would become the null value); + // it does not start with '{' or '[' so it stays a string. + const out = await streamToArray( + pipejoin([createReadableStream([{ a: "null" }]), csvCoerceValuesStream()]), + ); + strictEqual(out[0].a, "null"); +}); + +test(`${variant}: csvCoerceValuesStream does not JSON-parse a bare quoted-looking word`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream([{ a: "hello" }]), csvCoerceValuesStream()]), + ); + strictEqual(out[0].a, "hello"); +}); + +// --- csvDetectDelimitersStream quote-bracketing scan --- +test(`${variant}: csvDetectDelimitersStream requires a quote to start a field and close at a field end`, async (_t) => { + // Both apostrophes are mid-field; neither opens a field, so the quote char + // must default to ". + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray( + pipejoin([createReadableStream("ab'cd',ef\r\n"), detect]), + ); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream detects a quote that brackets a field after a delimiter`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("x,'a'\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects a quote bracketing the very first field`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("'a',b\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream ignores a quote that opens but never closes at a field end`, async (_t) => { + const detect = csvDetectDelimitersStream({ chunkSize: 1 }); + await streamToArray(pipejoin([createReadableStream("'twas,ok\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +// --- csvDetectHeaderStream: header-only and rest emission --- +test(`${variant}: csvDetectHeaderStream header-only input yields empty header value default and no rows`, async (_t) => { + // Empty input: processBuffer is never called (buffer length 0), so the result + // header stays its initial []. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream(""), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, []); + deepStrictEqual(out, []); +}); + +test(`${variant}: csvDetectHeaderStream header row with trailing newline and no data emits no data rows`, async (_t) => { + // rest is empty (header fills the buffer up to the trailing newline), so the + // rest.length > 0 guard must prevent emitting an empty chunk. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a,b,c\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b", "c"]); + deepStrictEqual(out, []); +}); + +test(`${variant}: csvDetectHeaderStream emits the data rows after the header`, async (_t) => { + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream("a,b\r\n1,2\r\n3,4\r\n"), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); + deepStrictEqual(out, [ + ["1", "2"], + ["3", "4"], + ]); +}); + +test(`${variant}: csvDetectHeaderStream passes later chunks through unchanged once header detected`, async (_t) => { + // chunkSize 1 so the header is detected on the first chunk; subsequent chunks + // must pass through (headerDetected branch), not be re-parsed as headers. + const hdr = csvDetectHeaderStream({ chunkSize: 1 }); + const out = await streamToArray( + pipejoin([ + createReadableStream(["a,b\r\n", "1,2\r\n", "3,4\r\n"]), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); + deepStrictEqual(out, [ + ["1", "2"], + ["3", "4"], + ]); +}); + +// --- csvParseStream chunk-type coercion (string vs Buffer) --- +test(`${variant}: csvParseStream parses string chunks without toString coercion`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream("a,b\r\nc,d\r\n"), csvParseStream()]), + ); + deepStrictEqual(out, [ + ["a", "b"], + ["c", "d"], + ]); +}); + +// --- numCols established on the quoted-field newline path --- +test(`${variant}: csvQuotedParser numCols from first row ending in a quoted field (escapeIsQuote)`, (_t) => { + // First row ends with "b" then newline (the quoted-field newline branch sets + // numCols); it must be 2 even though the next row has 3 columns. + const result = csvQuotedParser('a,"b"\r\n1,2,3\r\n'); + strictEqual(result.numCols, 2); +}); + +test(`${variant}: csvQuotedParser numCols from first row ending in a quoted field (custom escape)`, (_t) => { + const result = csvQuotedParser('a,"b"\r\n1,2,3\r\n', { escapeChar: "\\" }); + strictEqual(result.numCols, 2); +}); + +// --- ctx.tail is "" when flushing an unterminated quote --- +test(`${variant}: csvQuotedParser flushing an unterminated quote leaves no tail (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('"abc', {}, true); + strictEqual(result.tail, ""); + deepStrictEqual(result.rows, [["abc"]]); +}); + +test(`${variant}: csvQuotedParser flushing an unterminated quote leaves no tail (custom escape)`, (_t) => { + const result = csvQuotedParser('x,"abc', { escapeChar: "\\" }, true); + strictEqual(result.tail, ""); + deepStrictEqual(result.rows, [["x", "abc"]]); +}); + +// --- ctx.tail preserves the unterminated row when NOT flushing --- +test(`${variant}: csvQuotedParser not flushing keeps the unterminated quoted row as tail (escapeIsQuote)`, (_t) => { + const result = csvQuotedParser('a,b\r\n"abc', {}, false); + strictEqual(result.tail, '"abc'); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +test(`${variant}: csvQuotedParser not flushing keeps the unterminated quoted row as tail (custom escape)`, (_t) => { + const result = csvQuotedParser('a,b\r\nx,"abc', { escapeChar: "\\" }, false); + strictEqual(result.tail, 'x,"abc'); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +// --- trailing delimiter after a quoted field on flush (lastWasDelimiter) --- +test(`${variant}: csvQuotedParser quoted field then trailing delimiter adds an empty field on flush`, (_t) => { + // "a", then a delimiter sets lastWasDelimiter; flushing must append a trailing + // empty field for the dangling delimiter. + const result = csvQuotedParser('"a",', {}, true); + deepStrictEqual(result.rows, [["a", ""]]); +}); + +test(`${variant}: csvParseStream unquoted trailing delimiter adds an empty field on flush`, async (_t) => { + const out = await streamToArray( + pipejoin([createReadableStream("a,b,"), csvParseStream()]), + ); + deepStrictEqual(out, [["a", "b", ""]]); +}); + +// --- csvArrayToObject __proto__ must use defineProperty (not prototype set) --- +test(`${variant}: csvArrayToObject __proto__ column does not pollute the prototype`, async (_t) => { + const out = await streamToArray( + pipejoin([ + createReadableStream([["payload", "v2"]]), + csvArrayToObject({ headers: ["__proto__", "b"] }), + ]), + ); + const obj = out[0]; + // The column is an own enumerable data property, not a prototype assignment. + strictEqual( + Object.getOwnPropertyDescriptor(obj, "__proto__").value, + "payload", + ); + strictEqual(obj.b, "v2"); + deepStrictEqual(Object.keys(obj), ["__proto__", "b"]); +}); + +// --- csvDetectDelimitersStream empty input keeps the initialized value shape --- +test(`${variant}: csvDetectDelimitersStream empty input yields all-undefined detection value`, async (_t) => { + const detect = csvDetectDelimitersStream(); + const out = await streamToArray(pipejoin([createReadableStream(""), detect])); + // Empty input must not emit an empty chunk. + deepStrictEqual(out, []); + deepStrictEqual(detect.result().value, { + delimiterChar: undefined, + newlineChar: undefined, + quoteChar: undefined, + escapeChar: undefined, + }); +}); + +// --- numCols stays first-row count when later quoted-field rows differ --- +test(`${variant}: csvQuotedParser numCols stays first row when later quoted row is shorter (escapeIsQuote)`, (_t) => { + // Row 0 ("a","b") sets numCols via the quoted-field newline path; row 1 ("c") + // also ends via the quoted-field newline path but must NOT reset numCols. + const result = csvQuotedParser('"a","b"\r\n"c"\r\n'); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows, [["a", "b"], ["c"]]); +}); + +test(`${variant}: csvQuotedParser numCols stays first row when later quoted row is shorter (custom escape)`, (_t) => { + const result = csvQuotedParser('"a","b"\r\n"c"\r\n', { escapeChar: "\\" }); + strictEqual(result.numCols, 2); + deepStrictEqual(result.rows, [["a", "b"], ["c"]]); +}); + +// --- numCols established in the end-of-input cleanup path --- +test(`${variant}: csvQuotedParser numCols set in cleanup for a lone quoted field on flush`, (_t) => { + // A single quoted field with no newline, flushed: numCols is established in the + // final cleanup block from the field count. + const result = csvQuotedParser('"a"', {}, true); + strictEqual(result.numCols, 1); + deepStrictEqual(result.rows, [["a"]]); +}); + +// --- unescapeCustom: a trailing escape char at end-of-input is kept literally --- +test(`${variant}: csvQuotedParser custom escape unterminated field ending in a lone escape char keeps it literally`, (_t) => { + // Content is "a\" (a then a single backslash) with no following char. The + // trailing escape must be preserved as-is, not consume an out-of-range char. + const result = csvQuotedParser('"a\\', { escapeChar: "\\" }, true); + deepStrictEqual(result.rows, [["a\\"]]); + ok(result.errors.UnterminatedQuote); +}); + +// --- delimiter/newline tie-break: a delimiter that is a prefix of the newline --- +test(`${variant}: csvQuotedParser delimiter takes precedence when it is a prefix of the newline`, (_t) => { + // With delimiterChar "\r" and newlineChar "\r\n", a "\r\n" begins both a + // delimiter and a newline at the same index. The delimiter wins the tie, so + // "a\r\n" is the row ["a", "\n"] (the "\r" splits, the "\n" is the next field). + const result = csvQuotedParser( + "a\r\n", + { + delimiterChar: "\r", + newlineChar: "\r\n", + }, + true, + ); + deepStrictEqual(result.rows, [["a", "\n"]]); +}); + +test(`${variant}: csvDetectHeaderStream delimiter-prefix-of-newline tie consumes the CR as a delimiter`, async (_t) => { + // findRowEnd must apply the same tie-break: at each "\r\n" the "\r" delimiter + // is taken first, so no pure newline terminates the row — the whole buffer is + // the header and there are no data rows. (If the newline won the tie, the + // header would end at the first "\r".) + const hdr = csvDetectHeaderStream({ + delimiterChar: "\r", + newlineChar: "\r\n", + }); + const out = await streamToArray( + pipejoin([createReadableStream("a\rb\r\n"), hdr]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "b", "\n"]); + deepStrictEqual(out, []); +}); + +// --- csvParseInline: a fully consumed flush leaves an empty tail --- +test(`${variant}: csvQuotedParser flush of complete input leaves an empty tail`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2", {}, true); + strictEqual(result.tail, ""); + deepStrictEqual(result.rows, [ + ["a", "b"], + ["1", "2"], + ]); +}); + +test(`${variant}: csvQuotedParser non-flushing partial last row is returned as tail`, (_t) => { + const result = csvQuotedParser("a,b\r\n1,2", {}, false); + strictEqual(result.tail, "1,2"); + deepStrictEqual(result.rows, [["a", "b"]]); +}); + +// --- findRowEnd: single-column header (no delimiter present) --- +test(`${variant}: csvDetectHeaderStream single-column header has no delimiter`, async (_t) => { + // There is no delimiter in the buffer, so findRowEnd must still terminate the + // header at the first newline (the no-delimiter branch of the boundary check). + // The header newline sits at index 1, exercising the exact newline position. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a\r\nx\r\ny\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ["a"]); + deepStrictEqual(out, [["x"], ["y"]]); +}); + +// --- findRowEnd: a leading newline makes row 0 an empty header --- +test(`${variant}: csvDetectHeaderStream leading newline yields an empty header row`, async (_t) => { + // The buffer starts with the newline (at index 0), so row 0 is empty and the + // data follows. The row-end at index 0 must be returned, not skipped past a + // later delimiter. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("\r\na,b\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, []); + deepStrictEqual(out, [["a", "b"]]); +}); + +// --- findRowEnd: header newline precedes a later delimiter in the data --- +test(`${variant}: csvDetectHeaderStream one-char header ends at its newline before a data delimiter`, async (_t) => { + // The header's newline is at index 1, before any delimiter (the delimiter only + // appears in the data row). findRowEnd must terminate the header at that + // newline rather than skipping ahead to the data delimiter. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a\r\nb,c\r\n"), hdr, csvParseStream()]), + ); + deepStrictEqual(hdr.result().value.header, ["a"]); + deepStrictEqual(out, [["b", "c"]]); +}); + +// --- findRowEnd: an unterminated quote in the header means no complete row --- +test(`${variant}: csvDetectHeaderStream unterminated quote in header treats whole buffer as header`, async (_t) => { + // The opening quote is never closed in the buffer, so findRowEnd returns -1 and + // the entire input is parsed as the header with no data rows. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('"unterminated header\r\nmore\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, [ + "unterminated header\r\nmore\r\n", + ]); + deepStrictEqual(out, []); +}); + +// --- findRowEnd: the escape-run lookback must NOT count the opening quote --- +test(`${variant}: csvDetectHeaderStream leading doubled-quote header field keeps embedded newline`, async (_t) => { + // Header field content is ""a\r\nb : the leading "" is an escaped quote, then + // a\r\nb stays inside the field. The escape-run lookback must stop just after + // the opening quote — counting the opening quote would flip the parity and end + // the row at the embedded newline. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('"""a\r\nb",c\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['"a\r\nb', "c"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- findRowEnd: escaped quote at the very start of a quoted header field --- +test(`${variant}: csvDetectHeaderStream custom escape escaped quote at start of header field keeps embedded newline`, async (_t) => { + // Header field content begins with \" (an escaped quote at the first content + // position); the run-length lookback must include that leading escape so the + // field stays open across the embedded \r\n and the row ends at the real one. + const hdr = csvDetectHeaderStream({ escapeChar: "\\" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('"\\"a\r\nb",c\r\n1,2\r\n'), + hdr, + csvParseStream({ escapeChar: "\\" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ['"a\r\nb', "c"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- custom escape: an escaped quote at the very start of the field content --- +test(`${variant}: csvQuotedParser custom escape escaped quote at start of content`, (_t) => { + // Content is \"X : the leading \" is an escaped quote (the run-length lookback + // must include the escape char sitting at the first content position), so the + // field is "X and the closing quote is the final one. + const result = csvQuotedParser('"\\"X",b\r\n', { escapeChar: "\\" }); + deepStrictEqual(result.rows, [['"X', "b"]]); +}); + +// --- csvParseInline: a quote only opens a field at a field-start position --- +test(`${variant}: csvQuotedParser treats a mid-field quote as a literal character`, (_t) => { + // The " in a"b is not at a field start, so the field is the unquoted text a"b, + // not the start of a quoted field. + const result = csvQuotedParser('a"b,c\r\nd,e\r\n'); + deepStrictEqual(result.rows, [ + ['a"b', "c"], + ["d", "e"], + ]); +}); + +test(`${variant}: csvQuotedParser mid-field quote literal with custom escape`, (_t) => { + const result = csvQuotedParser('a"b,c\r\nd,e\r\n', { escapeChar: "\\" }); + deepStrictEqual(result.rows, [ + ['a"b', "c"], + ["d", "e"], + ]); +}); + +// --- findRowEnd: delimiter handling opens the following quoted field --- +test(`${variant}: csvDetectHeaderStream delimiter before a quoted field with an embedded newline`, async (_t) => { + // After the delimiter, fieldStart advances so the following quoted field opens; + // its embedded \r\n must stay inside the field and NOT end the header row. If + // the delimiter were not honored, the embedded newline would split the header. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([ + createReadableStream('a,"x\r\ny"\r\n1,2\r\n'), + hdr, + csvParseStream(), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "x\r\ny"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +test(`${variant}: csvDetectHeaderStream multi-char delimiter before a quoted field with an embedded newline`, async (_t) => { + const hdr = csvDetectHeaderStream({ delimiterChar: "::" }); + const out = await streamToArray( + pipejoin([ + createReadableStream('a::"x\r\ny"\r\n1::2\r\n'), + hdr, + csvParseStream({ delimiterChar: "::" }), + ]), + ); + deepStrictEqual(hdr.result().value.header, ["a", "x\r\ny"]); + deepStrictEqual(out, [["1", "2"]]); +}); + +// --- csvDetectDelimitersStream quote scan: closing quote search starts after opener --- +test(`${variant}: csvDetectDelimitersStream detects a quoted single-char field bracketed by quotes`, async (_t) => { + // The opener is at index 0 and the closer at index 2; the closing-quote search + // must begin after the opener and the run must terminate at the field end. + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("'a',b,c\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream detects a quoted field closing exactly at end of text`, async (_t) => { + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("a,'b'\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +test(`${variant}: csvDetectDelimitersStream does not detect a single opening quote with no closer`, async (_t) => { + // ' opens at the very start but the next char is a delimiter (a field end), so + // it never brackets a field; the closing-quote search must start AFTER the + // opener, leaving the quote char at the default ". + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("',b\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, '"'); +}); + +test(`${variant}: csvDetectDelimitersStream detects an empty quoted first field`, async (_t) => { + // '' is an empty quoted field bracketed at indices 0 and 1; the closer at + // index 1 must be considered (the close-search loop must accept index 0/1). + const detect = csvDetectDelimitersStream(); + await streamToArray(pipejoin([createReadableStream("'',b\r\n"), detect])); + strictEqual(detect.result().value.quoteChar, "'"); +}); + +// --- detect streams: input with no newline is emitted via flush (not lost) --- +test(`${variant}: csvDetectDelimitersStream emits a no-newline input via flush`, async (_t) => { + // No newline means detect() never succeeds in transform; flush must still emit + // the buffered data so nothing is dropped. + const detect = csvDetectDelimitersStream(); + const out = await streamToString( + pipejoin([createReadableStream("a,b,c"), detect]), + ); + strictEqual(out, "a,b,c"); +}); + +test(`${variant}: csvDetectHeaderStream emits a no-newline input as header via flush`, async (_t) => { + // The entire (newline-less) input is the header; flush processes it and emits + // no data rows. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a,b,c"), hdr]), + ); + deepStrictEqual(out, []); + deepStrictEqual(hdr.result().value.header, ["a", "b", "c"]); +}); + +// --- detect streams: emission chunking observed directly (no downstream parser) --- +test(`${variant}: csvDetectDelimitersStream emits each chunk as it detects, not coalesced`, async (_t) => { + // Once the first chunk completes a line, detection fires in transform and the + // chunk is emitted; the next chunk passes straight through as its own chunk. + const detect = csvDetectDelimitersStream(); + const out = await streamToArray( + pipejoin([createReadableStream(["a;b\r\n", "c;d\r\n"]), detect]), + ); + deepStrictEqual(out, ["a;b\r\n", "c;d\r\n"]); + strictEqual(detect.result().value.delimiterChar, ";"); +}); + +test(`${variant}: csvDetectHeaderStream emits the rest in transform, then later chunks separately`, async (_t) => { + // First chunk completes the header row → header detected in transform, the + // data remainder emitted, and the subsequent chunk passes through on its own. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream(["a,b\r\n1,2\r\n", "3,4\r\n"]), hdr]), + ); + deepStrictEqual(out, ["1,2\r\n", "3,4\r\n"]); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); +}); + +test(`${variant}: csvDetectHeaderStream header-only with trailing newline emits nothing (direct)`, async (_t) => { + // rest is empty, so the rest.length > 0 guard must suppress an empty emission. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream("a,b,c\r\n"), hdr]), + ); + deepStrictEqual(out, []); + deepStrictEqual(hdr.result().value.header, ["a", "b", "c"]); +}); + +test(`${variant}: csvDetectHeaderStream buffers a header split across chunks until the row completes`, async (_t) => { + // The header row is split across two chunks; processing must wait until the + // full first row is present, then emit the data remainder as one chunk. + const hdr = csvDetectHeaderStream(); + const out = await streamToArray( + pipejoin([createReadableStream(["a,", "b\r\n1,2\r\n"]), hdr]), + ); + deepStrictEqual(out, ["1,2\r\n"]); + deepStrictEqual(hdr.result().value.header, ["a", "b"]); +}); diff --git a/packages/csv/package.json b/packages/csv/package.json index 3bbc4e1..07c2ebc 100644 --- a/packages/csv/package.json +++ b/packages/csv/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/csv", - "version": "0.5.0", + "version": "0.6.1", "description": "CSV parsing and formatting transform streams", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -63,7 +64,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", - "@datastream/object": "0.5.0" + "@datastream/core": "0.6.1", + "@datastream/object": "0.6.1" } } diff --git a/packages/digest/README.md b/packages/digest/README.md index a57f4a1..4f302c7 100644 --- a/packages/digest/README.md +++ b/packages/digest/README.md @@ -1,6 +1,6 @@Hash and checksum streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/digest/index.d.ts b/packages/digest/index.d.ts index 014bca4..f379476 100644 --- a/packages/digest/index.d.ts +++ b/packages/digest/index.d.ts @@ -12,7 +12,11 @@ export type DigestAlgorithm = | "SHA2-512" | "SHA3-256" | "SHA3-384" - | "SHA3-512"; + | "SHA3-512" + // Native aliases accepted on both builds, normalized to the canonical name. + | "SHA256" + | "SHA384" + | "SHA512"; type DigestStreamResult = DatastreamPassThrough & { result: () => StreamResultDuckDB writable streams (copy-from).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You can read the documentation at: https://datastream.js.org
+HTTP fetch streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/fetch/index.js b/packages/fetch/index.js index 865b94a..f0f1491 100644 --- a/packages/fetch/index.js +++ b/packages/fetch/index.js @@ -22,6 +22,11 @@ const validatePaginationUrl = (nextUrl, origin) => { } }; +// URL.parse returns null (instead of throwing) on an unparseable URL, so the +// origin falls through to undefined without a try/catch — avoiding an +// error-swallowing catch body that is indistinguishable from an empty one. +const originOf = (urlString) => URL.parse(urlString)?.origin; + const redactUrl = (urlString) => { try { const url = new URL(urlString); @@ -79,7 +84,13 @@ export const fetchWritableStream = async (options, streamOptions = {}) => { const write = (chunk) => { body.push(chunk); }; - const stream = createWritableStream(write, streamOptions); + // Signal end-of-body so the duplex request body terminates and the + // in-flight upload can complete. createReadableStream treats a pushed + // `null` as close on both the Node and Web builds. + const final = () => { + body.push(null); + }; + const stream = createWritableStream(write, final, streamOptions); stream.result = () => ({ key: "output", value }); return stream; }; @@ -117,7 +128,11 @@ async function* fetchGenerator(fetchOptions, streamOptions) { yield chunk; } } catch (error) { + // Binary branch: response is a Web ReadableStream with .cancel(). + // JSON branch: response is the fetchJson async generator with + // .return(). Call both so resources are released uniformly. await response?.cancel?.(); + await response?.return?.(); throw error; } // ensure there is rate limiting between req with different options @@ -125,7 +140,11 @@ async function* fetchGenerator(fetchOptions, streamOptions) { } } -const jsonContentTypeRegExp = /^application\/(.+\+)?json($|;.+)/; +// `json` optionally followed by `;parameters` (or a bare trailing `;`). +// Note: the parameter portion is intentionally NOT `;.+` — that form admits a +// Stryker-equivalent mutant (`;.+` and `;.` accept the exact same inputs under +// `.test()`), so we match a bare `;` and let any following parameters be free. +const jsonContentTypeRegExp = /^application\/(.+\+)?json($|;)/; const fetchUnknown = async (options, streamOptions) => { const response = await fetchRateLimit(options, streamOptions); if (jsonContentTypeRegExp.test(response.headers.get("Content-Type"))) { @@ -146,6 +165,11 @@ async function* fetchJson(options, streamOptions) { options.prefetchResponse ?? (await fetchRateLimit(options, streamOptions)); delete options.prefetchResponse; + // NOTE: response.json() buffers the FULL body of this page into memory + // before any item is yielded — unlike the binary branch which streams + // chunk-by-chunk with backpressure. A server returning a very large + // single JSON document (or large pages) is fully materialized here, so + // keep per-page payloads bounded for untrusted endpoints. const body = await response.json(); url = parseLinkFromHeader(response.headers); url ??= parseNextPath(body, nextPath); @@ -187,13 +211,34 @@ const parseLinkFromHeader = (headers) => { return link?.match(nextLinkRegExp)?.[1]; }; -export const fetchRateLimit = async (options, streamOptions = {}) => { +// 3xx statuses that carry a Location and represent a redirect. +const redirectStatuses = new Set([301, 302, 303, 307, 308]); +const maxRedirects = 20; + +export const fetchRateLimit = async (options = {}, streamOptions = {}) => { + // Apply defaults FIRST so rateLimit (and every other option) is populated + // before it is read; otherwise a direct call without rateLimit computes + // `Date.now() + 1000 * undefined` = NaN for rateLimitTimestamp. + // Mutate the passed-in object in place (rather than reassigning to a clone) + // so callers — notably fetchGenerator, which reads back + // options.rateLimitTimestamp to carry rate limiting between configs — see + // the timestamp we compute below. + Object.assign(options, mergeOptions(options)); + const now = Date.now(); if (now < (options.rateLimitTimestamp ?? 0)) { await timeout(options.rateLimitTimestamp - now, streamOptions); } options.rateLimitTimestamp = Date.now() + 1000 * options.rateLimit; - options = mergeOptions(options); // for when called directly + + // Same-origin redirect pinning (SSRF defence). The initial origin is pinned + // to the original request URL; redirects to a different origin are blocked + // by default so a server cannot 3xx us into internal/metadata endpoints. + // Callers can opt out by setting `redirect` explicitly ("follow"/"error"). + const callerRedirect = options.redirect; + const manageRedirects = callerRedirect === undefined; + // __origin is set by fetchGenerator; derive it for direct callers. + const initialOrigin = options.__origin ?? originOf(options.url); const { method, @@ -202,7 +247,6 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { mode, credentials, cache, - redirect, referrer, referrerPolicy, integrity, @@ -216,7 +260,9 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { mode, credentials, cache, - redirect, + // When we manage redirects ourselves we ask the platform NOT to follow + // them so we can validate each Location before re-issuing. + redirect: manageRedirects ? "manual" : callerRedirect, referrer, referrerPolicy, integrity, @@ -224,7 +270,67 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { duplex, signal: streamOptions.signal, }; - const response = await fetch(options.url, fetchInit); + let response = await fetch(options.url, fetchInit); + + // Manually follow / validate redirects when we own redirect handling. + if (manageRedirects) { + let redirectCount = 0; + while ( + redirectStatuses.has(response.status) && + response.headers.has("Location") + ) { + const safeUrl = redactUrl(options.url); + if (++redirectCount > maxRedirects) { + await response.body?.cancel(); + throw new Error( + `fetch ${options.method} ${safeUrl} exceeded ${maxRedirects} redirects`, + { + cause: { + status: response.status, + url: safeUrl, + method: options.method, + }, + }, + ); + } + const location = response.headers.get("Location"); + let target; + try { + target = new URL(location, options.url); + } catch { + await response.body?.cancel(); + throw new Error( + `fetch ${options.method} ${safeUrl} returned an invalid redirect Location`, + { + cause: { + status: response.status, + url: safeUrl, + method: options.method, + }, + }, + ); + } + if (target.origin !== initialOrigin) { + await response.body?.cancel(); + throw new Error( + `fetch ${options.method} ${safeUrl} blocked cross-origin redirect (${target.origin} does not match ${initialOrigin})`, + { + cause: { + status: response.status, + url: safeUrl, + location: redactUrl(target.toString()), + origin: initialOrigin, + method: options.method, + }, + }, + ); + } + await response.body?.cancel(); + options.url = target.toString(); + response = await fetch(options.url, fetchInit); + } + } + if (!response.ok) { const safeUrl = redactUrl(options.url); // 429 Too Many Requests @@ -238,7 +344,7 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { { cause: { status: response.status, - url: options.url, + url: safeUrl, method: options.method, }, }, @@ -258,7 +364,7 @@ export const fetchRateLimit = async (options, streamOptions = {}) => { throw new Error(`fetch ${response.status} ${options.method} ${safeUrl}`, { cause: { status: response.status, - url: options.url, + url: safeUrl, method: options.method, }, }); diff --git a/packages/fetch/index.test.js b/packages/fetch/index.test.js index 5c20385..9790921 100644 --- a/packages/fetch/index.test.js +++ b/packages/fetch/index.test.js @@ -4,6 +4,7 @@ import { deepStrictEqual, ok, strictEqual } from "node:assert"; import test from "node:test"; import { createPassThroughStream, + createTransformStream, pipejoin, pipeline, streamToArray, @@ -165,6 +166,32 @@ global.fetch = (url, _request) => { throw new Error("mock missing"); }; +// *** built-in default Accept header (literal, before any pollution) *** // +// MUST run before any fetchSetDefaults({headers:{Accept:...}}) call below so it +// observes the un-overridden literal default `Accept: "application/json"`. +// Kills the StringLiteral mutant that empties the literal to "". +test(`${variant}: built-in default Accept header is non-empty application/json`, async (_t) => { + const originalFetch = global.fetch; + let capturedHeaders; + global.fetch = async (_url, init) => { + capturedHeaders = init.headers; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + try { + // Do NOT touch Accept anywhere: rely entirely on the literal default. + const stream = fetchResponseStream([ + { url: "https://example.org/builtin-accept", dataPath: "" }, + ]); + await streamToArray(stream); + strictEqual(capturedHeaders.Accept, "application/json"); + } finally { + global.fetch = originalFetch; + } +}); + // *** fetchResponseStream *** // test(`${variant}: fetchResponseStream should fetch csv`, async (_t) => { fetchSetDefaults({ headers: { Accept: "text/csv" } }); @@ -333,6 +360,238 @@ test(`${variant}: fetchResponseStream should throw on non-ok response`, async (_ } }); +test(`${variant}: fetchResponseStream error cause must not leak the unredacted URL`, async (_t) => { + const originalFetch = global.fetch; + const secretUrl = + "https://user:pass@example.org/secret?token=abc123&api_key=zzz"; + global.fetch = async (url) => { + if (url.startsWith("https://user:pass@example.org/secret")) { + return new Response("", { status: 404, statusText: "Not Found" }); + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([{ url: secretUrl }]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + // message is redacted + ok(!error.message.includes("token=abc123"), "message leaked query token"); + ok(!error.message.includes("user:pass"), "message leaked credentials"); + // cause.url MUST be redacted too + ok( + !String(error.cause.url).includes("token=abc123"), + `cause.url leaked query token: ${error.cause.url}`, + ); + ok( + !String(error.cause.url).includes("api_key=zzz"), + `cause.url leaked api_key: ${error.cause.url}`, + ); + ok( + !String(error.cause.url).includes("user:pass"), + `cause.url leaked credentials: ${error.cause.url}`, + ); + ok( + String(error.cause.url).includes("[REDACTED]"), + `cause.url not redacted: ${error.cause.url}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +test(`${variant}: fetchRateLimit 429 max-retries error cause must not leak the unredacted URL`, async (_t) => { + const originalFetch = global.fetch; + const secretUrl = "https://example.org/always-429?token=secret-429"; + global.fetch = () => + Promise.resolve( + new Response("rate limited", { + status: 429, + statusText: "Too Many Requests", + }), + ); + fetchSetDefaults({ rateLimit: 0 }); + try { + const stream = fetchResponseStream([ + { url: secretUrl, rateLimit: 0, retryMaxCount: 2 }, + ]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + ok(error.message.includes("max retries")); + ok( + !error.message.includes("token=secret-429"), + "message leaked query token", + ); + ok( + !String(error.cause.url).includes("token=secret-429"), + `cause.url leaked query token: ${error.cause.url}`, + ); + ok( + String(error.cause.url).includes("[REDACTED]"), + `cause.url not redacted: ${error.cause.url}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ rateLimit: 0.01 }); + } +}); + +// *** SSRF: redirect handling *** // +test(`${variant}: fetchResponseStream should block cross-origin redirects (SSRF)`, async (_t) => { + const originalFetch = global.fetch; + let metadataFetched = false; + global.fetch = async (url, init) => { + if (url === "https://example.org/redirect-ssrf") { + // Simulate a server 302-redirecting to a cloud metadata endpoint. + // With redirect:"manual" the platform returns the 3xx unfollowed. + deepStrictEqual(init.redirect, "manual"); + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({ + Location: "http://169.254.169.254/latest/meta-data/", + }), + }); + } + if (url === "http://169.254.169.254/latest/meta-data/") { + metadataFetched = true; + return new Response("creds", { status: 200 }); + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-ssrf" }, + ]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + ok( + error.message.includes("redirect"), + `expected redirect error, got: ${error.message}`, + ); + strictEqual(metadataFetched, false, "cross-origin redirect was followed"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +test(`${variant}: fetchResponseStream cross-origin redirect error must not leak unredacted URL`, async (_t) => { + const originalFetch = global.fetch; + const secretUrl = "https://example.org/redirect-secret?token=abc123"; + global.fetch = async (url) => { + if (url === secretUrl) { + return new Response("", { + status: 301, + statusText: "Moved Permanently", + headers: new Headers({ + Location: "http://127.0.0.1:9000/internal?password=leak", + }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([{ url: secretUrl }]); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (error) { + ok( + !error.message.includes("token=abc123"), + "redirect error leaked source token", + ); + ok( + !error.message.includes("password=leak"), + "redirect error leaked destination secret", + ); + ok( + !String(error.cause?.url ?? "").includes("token=abc123"), + `cause.url leaked source token: ${error.cause?.url}`, + ); + ok( + !String(error.cause?.location ?? "").includes("password=leak"), + `cause.location leaked destination secret: ${error.cause?.location}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +test(`${variant}: fetchResponseStream should follow same-origin redirects`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/redirect-same") { + return new Response("", { + status: 302, + statusText: "Found", + headers: new Headers({ + Location: "https://example.org/redirect-target", + }), + }); + } + if (url === "https://example.org/redirect-target") { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "", headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-same" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ + dataPath: undefined, + headers: { Accept: "application/json" }, + }); + } +}); + +test(`${variant}: fetchResponseStream should honor explicit redirect option (opt-in follow)`, async (_t) => { + const originalFetch = global.fetch; + let sawFollow = false; + global.fetch = async (url, init) => { + if (url === "https://example.org/redirect-optin") { + sawFollow = init.redirect === "follow"; + // With redirect:"follow" the platform resolves the redirect itself, + // so the mock just returns the final response. + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + fetchSetDefaults({ dataPath: "", headers: { Accept: "application/json" } }); + try { + const stream = fetchResponseStream([ + { url: "https://example.org/redirect-optin", redirect: "follow" }, + ]); + const output = await streamToArray(stream); + deepStrictEqual(output, [{ ok: true }]); + ok(sawFollow, "explicit redirect:follow was not passed through"); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ + dataPath: undefined, + headers: { Accept: "application/json" }, + }); + } +}); + // *** fetchWritableStream *** // test(`${variant}: fetchWritableStream should create writable stream for upload`, async (_t) => { const originalFetch = global.fetch; @@ -364,7 +623,47 @@ test(`${variant}: fetchWritableStream should create writable stream for upload`, global.fetch = originalFetch; }); -test(`${variant}: fetchRateLimit should handle rate limit delay`, async (_t) => { +test(`${variant}: fetchWritableStream should close the request body on end`, async (_t) => { + const originalFetch = global.fetch; + + let resolveBody; + const bodyDone = new Promise((resolve) => { + resolveBody = resolve; + }); + + global.fetch = async (_url, options) => { + // Drain the duplex request body to completion in the background. + // If end-of-body is never signaled, this never resolves and the + // test times out. + (async () => { + const received = []; + for await (const chunk of options.body) { + received.push(chunk); + } + resolveBody(received); + })(); + return new Response(JSON.stringify({ uploaded: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + }; + + const stream = await fetchWritableStream({ + url: "https://example.org/upload", + method: "POST", + }); + + stream.write("alpha"); + stream.write("beta"); + stream.end(); + + const received = await bodyDone; + deepStrictEqual(received, ["alpha", "beta"]); + + global.fetch = originalFetch; +}); + +test(`${variant}: fetchRateLimit should delay between configs within one stream`, async (_t) => { const originalFetch = global.fetch; const fetchCallTimes = []; @@ -376,21 +675,46 @@ test(`${variant}: fetchRateLimit should handle rate limit delay`, async (_t) => }); }; - fetchSetDefaults({ rateLimit: 0.1 }); // 100ms delay - - const config1 = [{ url: "https://example.org/test1" }]; - const stream1 = fetchResponseStream(config1); - await streamToArray(stream1); - - const config2 = [{ url: "https://example.org/test2" }]; - const stream2 = fetchResponseStream(config2); - await streamToArray(stream2); + // A single config array of >=2 entries so the in-generator + // rateLimitTimestamp carries over between the two requests. + const config = [ + { url: "https://example.org/test1", rateLimit: 0.1, dataPath: "" }, + { url: "https://example.org/test2", rateLimit: 0.1, dataPath: "" }, + ]; + const stream = fetchResponseStream(config); + await streamToArray(stream); global.fetch = originalFetch; - fetchSetDefaults({ rateLimit: 0.01 }); // Reset to default - // Second call should have been delayed strictEqual(fetchCallTimes.length, 2); + // rateLimit 0.1 => ~100ms enforced delay between the two calls. + ok( + fetchCallTimes[1] - fetchCallTimes[0] >= 90, + `expected >=~100ms delay between configs, got ${ + fetchCallTimes[1] - fetchCallTimes[0] + }ms`, + ); +}); + +test(`${variant}: fetchRateLimit direct call defaults rateLimit before computing timestamp`, async (_t) => { + const { fetchRateLimit } = await import("@datastream/fetch"); + const originalFetch = global.fetch; + global.fetch = async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + try { + // Direct call with no rateLimit in options: timestamp must NOT be NaN. + const options = { url: "https://example.org/direct-rl" }; + await fetchRateLimit(options); + ok( + Number.isFinite(options.rateLimitTimestamp), + `rateLimitTimestamp should be finite, got ${options.rateLimitTimestamp}`, + ); + } finally { + global.fetch = originalFetch; + } }); test(`${variant}: fetchResponseStream should handle content-type with suffix`, async (_t) => { @@ -504,6 +828,158 @@ test(`${variant}: fetchRateLimit should throw after max retries on persistent 42 } }); +// *** JSON pagination cleanup on downstream error *** // +test(`${variant}: fetchResponseStream should stop JSON pagination when consumer errors`, async (_t) => { + const originalFetch = global.fetch; + let page2Fetched = false; + global.fetch = async (url) => { + if (url === "https://example.org/cancel-json/1") { + return new Response( + JSON.stringify({ + data: [{ id: 1 }, { id: 2 }], + next: "https://example.org/cancel-json/2", + }), + { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }, + ); + } + if (url === "https://example.org/cancel-json/2") { + page2Fetched = true; + return new Response(JSON.stringify({ data: [{ id: 3 }], next: "" }), { + status: 200, + headers: new Headers({ "Content-Type": "application/json" }), + }); + } + return originalFetch(url); + }; + + fetchSetDefaults({ dataPath: "data", nextPath: "next" }); + const failing = createTransformStream(() => { + throw new Error("downstream boom"); + }); + try { + await pipeline([ + fetchResponseStream({ url: "https://example.org/cancel-json/1" }), + failing, + ]); + throw new Error("Should have thrown"); + } catch (e) { + ok(e.message.includes("downstream boom")); + // On error, the JSON generator must be returned so pagination stops; + // page 2 must never be fetched. + strictEqual(page2Fetched, false); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ dataPath: undefined, nextPath: undefined }); + } +}); + +// *** Binary branch cleanup on downstream error *** +// When the response is NON-JSON, fetchGenerator iterates the raw ReadableStream +// (response.body), which has .cancel() but NO .return(). A downstream consumer +// error must propagate; the catch block calls response?.cancel?.() then +// response?.return?.(). The ReadableStream lacks .return, so the inner optional +// chaining on `.return` MUST be preserved — a mutant turning `response?.return?.()` +// into `response?.return()` would throw "response.return is not a function" and +// mask the real "downstream boom" error. +test(`${variant}: fetchResponseStream binary branch cleanup on read error preserves original error`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/binary-cancel") { + // text/csv → non-JSON → binary branch: fetchGenerator iterates + // response.body directly. We install a body that HAS .cancel() + // (resolving cleanly) but NO .return(), and whose async iterator + // throws — so the catch runs, response?.cancel?.() succeeds, and we + // reach response?.return?.() with response lacking .return. + const r = new Response("ignored", { + status: 200, + headers: new Headers({ "Content-Type": "text/csv" }), + }); + const fakeBody = { + cancel: async () => {}, + [Symbol.asyncIterator]() { + return { + next: async () => { + throw new Error("binary read boom"); + }, + }; + }, + }; + Object.defineProperty(r, "body", { + value: fakeBody, + configurable: true, + }); + return r; + } + return originalFetch(url); + }; + fetchSetDefaults({ headers: { Accept: "text/csv" } }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/binary-cancel", + }); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (e) { + // Original code: response?.return?.() no-ops (stream lacks .return), so the + // ORIGINAL read error surfaces. Mutant `response?.return()` calls a + // non-function and throws "response.return is not a function", masking it. + ok( + e.message.includes("binary read boom"), + `expected original read error, got: ${e.message}`, + ); + ok( + !e.message.includes("is not a function"), + `cleanup called a non-existent .return(): ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + +// *** Null response cleanup: catch fires with response === null/undefined *** +// A non-JSON response whose body is null makes fetchUnknown return null, so +// `for await (const chunk of null)` throws and the catch block runs with +// `response === null`. The outer optional chaining `response?.cancel?.()` and +// `response?.return?.()` MUST be preserved: a mutant removing the first `?.` +// (response.cancel?.() / response.return()) dereferences null and throws a +// "Cannot read properties of null" TypeError, replacing the genuine iteration +// error. We assert the surfaced error is the iteration error, not the null deref. +test(`${variant}: fetchResponseStream null response body surfaces iteration error not null-deref`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async () => { + const r = new Response("ignored", { + status: 200, + headers: new Headers({ "Content-Type": "text/csv" }), + }); + // Force a null body so fetchUnknown returns null and iteration throws. + Object.defineProperty(r, "body", { value: null, configurable: true }); + return r; + }; + fetchSetDefaults({ headers: { Accept: "text/csv" } }); + try { + const stream = fetchResponseStream({ + url: "https://example.org/null-binary-body", + }); + await streamToArray(stream); + throw new Error("Should have thrown"); + } catch (e) { + // Original code: re-throws the iteration error (null is not iterable). + // Mutant (response.cancel?.()): throws "Cannot read properties of null + // (reading 'cancel')" BEFORE reaching `throw error`. + ok( + !/reading 'cancel'|reading 'return'/.test(e.message), + `cleanup dereferenced null instead of guarding it: ${e.message}`, + ); + } finally { + global.fetch = originalFetch; + fetchSetDefaults({ headers: { Accept: "application/json" } }); + } +}); + // *** SSRF protection: same-origin pagination *** // test(`${variant}: fetchResponseStream should reject Link header with cross-origin URL`, async (_t) => { const originalFetch = global.fetch; @@ -653,3 +1129,1505 @@ test(`${variant}: default export should include all stream functions`, (_t) => { "setDefaults", ]); }); + +// *** validatePaginationUrl: invalid URL catch block and error message *** // +test(`${variant}: fetchResponseStream should throw with message for invalid pagination URL in Link header`, async (_t) => { + const originalFetch = global.fetch; + global.fetch = async (url) => { + if (url === "https://example.org/bad-link") { + return new Response(JSON.stringify({ data: [{ id: 1 }] }), { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + Link: 'File system streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/file/index.node.js b/packages/file/index.node.js index 66603c5..fda6ee9 100644 --- a/packages/file/index.node.js +++ b/packages/file/index.node.js @@ -33,12 +33,14 @@ export const fileWriteStream = ( if (basePath != null) { const fd = openSync( path, - constants.O_WRONLY | - constants.O_CREAT | - constants.O_TRUNC | - constants.O_NOFOLLOW, + constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, ); - return createWriteStream(null, { ...makeOptions(streamOptions), fd }); + // `flags` is ignored by createWriteStream when an `fd` is supplied (the + // file is already open), so it is omitted here. + return createWriteStream(null, { + ...makeOptions(streamOptions), + fd, + }); } return createWriteStream(path, makeOptions(streamOptions)); }; diff --git a/packages/file/index.test.js b/packages/file/index.test.js index 196cfa8..7b5a22a 100644 --- a/packages/file/index.test.js +++ b/packages/file/index.test.js @@ -1,7 +1,13 @@ // Copyright 2026 will Farrell, and datastream contributors. // SPDX-License-Identifier: MIT import { deepStrictEqual, strictEqual, throws } from "node:assert"; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -38,6 +44,87 @@ test(`${variant}: fileReadStream should read a file`, async () => { strictEqual(output, testContent); }); +// *** fileReadStream with basePath - success case *** // +test(`${variant}: fileReadStream with basePath should read a file`, async () => { + const stream = fileReadStream({ path: testFile, basePath: testDir }); + const output = await streamToString(stream); + strictEqual(output, testContent); +}); + +// *** fileReadStream without basePath should follow symlinks *** // +test(`${variant}: fileReadStream without basePath can read through symlink`, async () => { + const linkPath = join(testDir, "sym-no-base.csv"); + try { + symlinkSync(testFile, linkPath); + } catch (_) { + // already exists + } + // Without basePath, no O_NOFOLLOW - symlinks are followed (read succeeds) + const stream = fileReadStream({ path: linkPath }); + const output = await streamToString(stream); + strictEqual(output, testContent); +}); + +// *** fileWriteStream without basePath - follows symlinks *** // +test(`${variant}: fileWriteStream without basePath can write through symlink`, async () => { + const outFile = join(testDir, "real-target.csv"); + writeFileSync(outFile, ""); + const writeLinkPath = join(testDir, "write-sym-no-base.csv"); + try { + symlinkSync(outFile, writeLinkPath); + } catch (_) { + // already exists + } + // Without basePath: no O_NOFOLLOW - symlinks are followed (write succeeds) + const stream = fileWriteStream({ path: writeLinkPath }); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("via-symlink")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "via-symlink"); +}); + +// *** fileWriteStream with basePath - success case *** // +test(`${variant}: fileWriteStream with basePath should write a new file`, async () => { + const outFile = join(testDir, "written.csv"); + const stream = fileWriteStream({ path: outFile, basePath: testDir }); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("written content")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "written content"); +}); + +// *** fileReadStream with basePath - streamOptions passed through *** // +test(`${variant}: fileReadStream with basePath passes streamOptions`, async () => { + const stream = fileReadStream( + { path: testFile, basePath: testDir }, + { highWaterMark: 1 }, + ); + const output = await streamToString(stream); + strictEqual(output, testContent); +}); + +// *** fileWriteStream with basePath - streamOptions passed through *** // +test(`${variant}: fileWriteStream with basePath passes streamOptions`, async () => { + const outFile = join(testDir, "opts.csv"); + const stream = fileWriteStream( + { path: outFile, basePath: testDir }, + { highWaterMark: 1 }, + ); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("opts")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "opts"); +}); + // *** Path traversal *** // test(`${variant}: fileReadStream should reject path traversal`, () => { throws(() => fileReadStream({ path: "/etc/passwd", basePath: testDir }), { @@ -71,15 +158,44 @@ test(`${variant}: fileReadStream should reject sibling-prefix bypass`, () => { ); }); +// *** Path traversal - basePath itself (rel === '') *** // +test(`${variant}: fileReadStream rejects when path equals basePath`, () => { + throws(() => fileReadStream({ path: testDir, basePath: testDir }), { + message: "Path traversal detected", + }); +}); + +test(`${variant}: fileWriteStream rejects when path equals basePath`, () => { + throws(() => fileWriteStream({ path: testDir, basePath: testDir }), { + message: "Path traversal detected", + }); +}); + // *** Symlink rejection *** // test(`${variant}: fileReadStream should reject symlinks`, () => { const linkPath = join(testDir, "link.csv"); - symlinkSync(testFile, linkPath); + try { + symlinkSync(testFile, linkPath); + } catch (_) { + // already exists + } throws(() => fileReadStream({ path: linkPath, basePath: testDir }), { message: "Symbolic links are not allowed", }); }); +test(`${variant}: fileWriteStream should reject symlinks`, () => { + const linkPath = join(testDir, "write-link.csv"); + try { + symlinkSync(testFile, linkPath); + } catch (_) { + // already exists + } + throws(() => fileWriteStream({ path: linkPath, basePath: testDir }), { + message: "Symbolic links are not allowed", + }); +}); + // *** Extension enforcement *** // test(`${variant}: fileReadStream should accept matching extension`, async () => { const stream = fileReadStream({ path: testFile, types: csvTypes }); @@ -108,12 +224,82 @@ test(`${variant}: fileWriteStream should reject non-matching extension`, () => { ); }); +// *** ENOENT is allowed (new file for write) *** // +test(`${variant}: fileWriteStream with basePath allows new file (ENOENT ok)`, async () => { + const outFile = join(testDir, "brand-new.csv"); + const stream = fileWriteStream({ path: outFile, basePath: testDir }); + await new Promise((resolve, reject) => { + stream.on("finish", resolve); + stream.on("error", reject); + stream.write(Buffer.from("new")); + stream.end(); + }); + strictEqual(readFileSync(outFile, "utf8"), "new"); +}); + +// *** Path not found for non-ENOENT stat errors *** // +test(`${variant}: fileReadStream throws Path not found for non-ENOENT stat error`, () => { + // testFile is a regular file, not a directory - accessing subpath causes ENOTDIR + const notDir = join(testFile, "inside.csv"); + throws(() => fileReadStream({ path: notDir, basePath: testDir }), { + message: "Path not found", + }); +}); + +// *** Path not found error has a cause *** // +test(`${variant}: fileReadStream Path not found error carries cause`, () => { + const notDir = join(testFile, "inside.csv"); + throws( + () => fileReadStream({ path: notDir, basePath: testDir }), + (e) => { + strictEqual(e.message, "Path not found"); + strictEqual(typeof e.cause, "object"); + return true; + }, + ); +}); + // *** No basePath = no path checks *** // test(`${variant}: fileReadStream allows any path without basePath`, async () => { const stream = fileReadStream({ path: testFile }); await streamToString(stream); }); +// *** fileWriteStream with basePath: failed constructor leaves existing file intact *** // +test(`${variant}: fileWriteStream with basePath preserves existing file when stream constructor fails`, () => { + const guardFile = join(testDir, "guard.csv"); + const originalContent = "important,data\n1,2,3\n"; + writeFileSync(guardFile, originalContent); + // {start:-5} is an invalid option that makes createWriteStream throw synchronously + throws( + () => + fileWriteStream({ path: guardFile, basePath: testDir }, { start: -5 }), + (e) => typeof e === "object" && e !== null, + ); + // The file must still contain its original content — not be wiped to zero bytes + strictEqual(readFileSync(guardFile, "utf8"), originalContent); +}); + +// *** fd-based streams when basePath is provided *** // +// When basePath is set, fileReadStream must use openSync+O_NOFOLLOW and pass the +// resulting fd to createReadStream (not the file path). The returned stream's .fd +// property is already a number in that case, whereas a path-based stream has .fd===null +// until the OS opens it. Mutants that skip the if(basePath!=null) block would produce +// a path-based stream whose .fd is null at construction time. +test(`${variant}: fileReadStream with basePath returns an fd-based stream`, () => { + const stream = fileReadStream({ path: testFile, basePath: testDir }); + strictEqual(typeof stream.fd, "number"); + stream.destroy(); +}); + +test(`${variant}: fileWriteStream with basePath returns an fd-based stream`, () => { + const outFile = join(testDir, "fd-write-check.csv"); + writeFileSync(outFile, ""); + const stream = fileWriteStream({ path: outFile, basePath: testDir }); + strictEqual(typeof stream.fd, "number"); + stream.destroy(); +}); + // *** default export *** // test(`${variant}: default export should include all stream functions`, () => { deepStrictEqual(Object.keys(fileDefault).sort(), [ diff --git a/packages/file/index.web.js b/packages/file/index.web.js index fbdc19c..95cd95a 100644 --- a/packages/file/index.web.js +++ b/packages/file/index.web.js @@ -2,9 +2,26 @@ // SPDX-License-Identifier: MIT import { createReadableStream } from "@datastream/core"; +const enforceType = (name, types = []) => { + if (!types.length) return; + const dotIdx = name.lastIndexOf("."); + const pathExt = dotIdx >= 0 ? name.slice(dotIdx) : ""; + for (const type of types) { + for (const mime in type.accept) { + for (const ext of type.accept[mime]) { + if (pathExt === ext) { + return; + } + } + } + } + throw new Error("Invalid extension"); +}; + export const fileReadStream = async ({ types }, _streamOptions = {}) => { const [fileHandle] = await window.showOpenFilePicker({ types }); const fileData = await fileHandle.getFile(); + enforceType(fileData.name, types); return createReadableStream(fileData); }; @@ -13,6 +30,7 @@ export const fileWriteStream = async ({ path, types }, _streamOptions = {}) => { suggestedName: path, types, }); + enforceType(fileHandle.name, types); return fileHandle.createWritable(); }; diff --git a/packages/file/package.json b/packages/file/package.json index ddc35a0..8184c18 100644 --- a/packages/file/package.json +++ b/packages/file/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/file", - "version": "0.5.0", + "version": "0.6.1", "description": "File system readable and writable streams with extension type enforcement", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,6 +61,6 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0" + "@datastream/core": "0.6.1" } } diff --git a/packages/indexeddb/README.md b/packages/indexeddb/README.md index 826d3ba..a837b0b 100644 --- a/packages/indexeddb/README.md +++ b/packages/indexeddb/README.md @@ -1,6 +1,6 @@IndexedDB streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/indexeddb/index.perf.js b/packages/indexeddb/index.perf.js new file mode 100644 index 0000000..1e47cb1 --- /dev/null +++ b/packages/indexeddb/index.perf.js @@ -0,0 +1,108 @@ +// Copyright 2026 will Farrell, and datastream contributors. +// SPDX-License-Identifier: MIT +import test from "node:test"; +import { createReadableStream, pipeline } from "@datastream/core"; +import { Bench } from "tinybench"; +// Under `node --test` the package entry (index.node.mjs) throws "Not supported" +// because there is no IndexedDB in Node. Per project convention (see +// index.test.js), the real web implementation is exercised directly with an +// `idb`-shaped mock so the streams do real work instead of throwing. +import { indexedDBReadStream, indexedDBWriteStream } from "./index.web.js"; + +// -- Mock idb (mirrors the shapes used in index.test.js) -- + +// Stores/indexes return async iterators of IDBCursor-shaped objects ({ value }). +// `iterate(key)` filters by `name`, matching the real index semantics. +const makeCursors = (records) => ({ + async *[Symbol.asyncIterator]() { + for (const record of records) { + yield { value: record }; + } + }, +}); +const makeStore = (records) => ({ + iterate: () => makeCursors(records), + index: (_name) => ({ + iterate: (key) => makeCursors(records.filter((r) => r.name === key)), + }), + add: async (record) => { + records.push(record); + }, +}); +const makeDb = (records) => ({ + transaction: (_store, _mode) => ({ + store: makeStore(records), + done: Promise.resolve(), + }), +}); + +const drain = async (stream) => { + for await (const _chunk of stream) { + // consume + } +}; + +// -- Data generators -- + +const time = Number(process.env.BENCH_TIME ?? 5_000); +const ITEMS = 10_000; + +const records = Array.from({ length: ITEMS }, (_, i) => ({ + id: i, + name: i % 2 === 0 ? "even" : "odd", + value: `value_${i}`, +})); + +// -- Tests -- + +test("perf: indexedDBReadStream", async () => { + const bench = new Bench({ name: "indexedDBReadStream", time }); + + bench.add(`${ITEMS} records`, async () => { + const stream = await indexedDBReadStream({ + db: makeDb(records), + store: "test-store", + }); + await drain(stream); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: indexedDBReadStream via index + key", async () => { + const bench = new Bench({ name: "indexedDBReadStream index", time }); + + bench.add(`${ITEMS} records, index "name"`, async () => { + const stream = await indexedDBReadStream({ + db: makeDb(records), + store: "test-store", + index: "name", + key: "even", + }); + await drain(stream); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); + +test("perf: indexedDBWriteStream", async () => { + const bench = new Bench({ name: "indexedDBWriteStream", time }); + + bench.add(`${ITEMS} records`, async () => { + // Fresh empty store per iteration so the backing array doesn't grow + // unbounded across runs. + const writeStream = await indexedDBWriteStream({ + db: makeDb([]), + store: "test-store", + }); + await pipeline([createReadableStream(records), writeStream]); + }); + + await bench.run(); + console.log(`\n${bench.name}`); + console.table(bench.table()); +}); diff --git a/packages/indexeddb/index.test.js b/packages/indexeddb/index.test.js index f281b81..25097d8 100644 --- a/packages/indexeddb/index.test.js +++ b/packages/indexeddb/index.test.js @@ -1,6 +1,6 @@ -import { deepStrictEqual, ok, strictEqual } from "node:assert"; +import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert"; import test from "node:test"; -import { +import indexeddbDefault, { indexedDBReadStream, indexedDBWriteStream, } from "@datastream/indexeddb"; @@ -111,44 +111,237 @@ if (!isBrowser) { strictEqual(e.message, "indexedDBWriteStream: Not supported"); } }); + + test(`${variant}: default export should expose readStream and writeStream`, (_t) => { + strictEqual(typeof indexeddbDefault.readStream, "function"); + strictEqual(typeof indexeddbDefault.writeStream, "function"); + // They must be the named exports (same reference). + strictEqual(indexeddbDefault.readStream, indexedDBReadStream); + strictEqual(indexeddbDefault.writeStream, indexedDBWriteStream); + }); } -// *** web variant: indexedDBReadStream with index *** // -if (variant === "webstream") { - test(`${variant}: indexedDBReadStream should use index and key when provided`, { - skip: "requires web implementation", - }, async (_t) => { - const mockCursor = { - async *[Symbol.asyncIterator]() { - yield { id: 1, name: "a" }; - yield { id: 2, name: "b" }; - }, +// *** web variant: exercise the real web implementation directly *** // +// Per project memory, `--conditions=webstream` loads the node build, so the web +// code is exercised by importing `index.web.js` directly with mocked `idb` +// objects shaped like the real library (async iterators that yield IDBCursor +// objects exposing `.value`). +// +// NOTE: `streamToArray` uses event-based stream consumption which doesn't work +// under `--test-force-exit` (used by Stryker). Use `for await` instead so the +// test Promise settles before Node forces an exit. +const readAll = async (stream) => { + const out = []; + for await (const chunk of stream) { + out.push(chunk); + } + return out; +}; +{ + const { + indexedDBReadStream: webReadStream, + indexedDBWriteStream: webWriteStream, + } = await import("./index.web.js"); + + // Build a mock that mimics idb: stores/indexes return async iterators of + // IDBCursor-shaped objects ({ value }). `iterate(key)` filters by `name`. + const makeCursors = (records) => ({ + async *[Symbol.asyncIterator]() { + for (const record of records) { + yield { value: record }; + } + }, + }); + const makeStore = (records) => ({ + iterate: () => makeCursors(records), + index: (_name) => ({ + iterate: (key) => makeCursors(records.filter((r) => r.name === key)), + }), + add: async (record) => { + records.push(record); + }, + }); + const makeDb = (records, { onTransaction } = {}) => ({ + transaction: (_store, _mode) => { + onTransaction?.(); + return { store: makeStore(records), done: Promise.resolve() }; + }, + }); + + test(`web: indexedDBReadStream yields stored records (cursor.value), not raw cursors`, async (_t) => { + const records = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + }); + + const output = await readAll(stream); + + deepStrictEqual(output.length, 2); + // Real stored records, not IDBCursor objects. + deepStrictEqual(output[0], { id: 1, name: "a" }); + deepStrictEqual(output[1], { id: 2, name: "b" }); + // Guard against the regression of emitting the wrapping cursor object. + strictEqual(output[0].value, undefined); + }); + + test(`web: indexedDBReadStream uses index + key when provided`, async (_t) => { + const records = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "a" }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + index: "name", + key: "a", + }); + + const output = await readAll(stream); + + // Only the two records whose name === "a" come back via the index. + strictEqual(output.length, 2); + deepStrictEqual(output[0], { id: 1, name: "a" }); + deepStrictEqual(output[1], { id: 3, name: "a" }); + }); + + test(`web: indexedDBReadStream uses the index for a falsy-but-valid key (0)`, async (_t) => { + // key === 0 is a valid IndexedDB key. The store records use name 0 vs 1. + const records = [ + { id: 1, name: 0 }, + { id: 2, name: 1 }, + { id: 3, name: 0 }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + index: "name", + key: 0, + }); + + const output = await readAll(stream); + + // With the buggy `if (index && key)` guard, key 0 is falsy and the whole + // store (all 3) would be returned instead of the 2 name===0 records. + strictEqual(output.length, 2); + deepStrictEqual(output[0], { id: 1, name: 0 }); + deepStrictEqual(output[1], { id: 3, name: 0 }); + }); + + test(`web: indexedDBReadStream uses the index for an empty-string key ("")`, async (_t) => { + const records = [ + { id: 1, name: "" }, + { id: 2, name: "x" }, + ]; + const stream = await webReadStream({ + db: makeDb(records), + store: "test", + index: "name", + key: "", + }); + + const output = await readAll(stream); + + strictEqual(output.length, 1); + deepStrictEqual(output[0], { id: 1, name: "" }); + }); + + test(`web: indexedDBReadStream removes the abort listener on normal completion`, async (_t) => { + // Spy on a real AbortController's listener registration to assert the + // wrapper's own "abort" listener is removed once iteration settles, so a + // shared signal does not accumulate one listener per constructed stream. + const controller = new AbortController(); + const { signal } = controller; + let listeners = 0; + const realAdd = signal.addEventListener.bind(signal); + const realRemove = signal.removeEventListener.bind(signal); + signal.addEventListener = (...args) => { + listeners += 1; + return realAdd(...args); }; - const mockIndex = { - iterate: (_key) => mockCursor, + signal.removeEventListener = (...args) => { + listeners -= 1; + return realRemove(...args); }; - const mockStore = { - index: (_name) => mockIndex, - [Symbol.asyncIterator]: async function* () { - yield { id: 1, name: "a" }; - yield { id: 2, name: "b" }; - yield { id: 3, name: "c" }; + const records = [{ id: 1, name: "a" }]; + + const stream = await webReadStream( + { db: makeDb(records), store: "test" }, + { signal }, + ); + await readAll(stream); + // Allow the underlying stream's terminal "close" handlers to run so any + // listener teardown (wrapper + core) has settled before asserting. + await new Promise((resolve) => setImmediate(resolve)); + + // On normal completion every registered listener must be removed (the + // wrapper's "abort" listener leaked here before the fix). + strictEqual(listeners, 0); + }); + + test(`web: indexedDBReadStream stops iterating once the signal aborts`, async (_t) => { + const controller = new AbortController(); + let produced = 0; + const cursors = { + async *[Symbol.asyncIterator]() { + for (let id = 1; id <= 5; id++) { + produced += 1; + // Abort partway through to verify iteration breaks early. + if (id === 2) controller.abort(); + yield { value: { id } }; + } }, }; - const mockDb = { - transaction: (_store) => ({ store: mockStore }), + const db = { + transaction: () => ({ + store: { iterate: () => cursors, index: () => ({}) }, + done: Promise.resolve(), + }), }; - const stream = await indexedDBReadStream({ - db: mockDb, - store: "test", - index: "name", - key: "a", + const stream = await webReadStream( + { db, store: "test" }, + { signal: controller.signal }, + ); + + // Aborting must stop the stream rather than draining all 5 records. + await rejects( + async () => { + for await (const _ of stream) { + // drain + } + }, + { name: "AbortError" }, + ); + ok(produced < 5, `expected early stop, produced ${produced}`); + }); + + test(`web: indexedDBWriteStream opens a fresh transaction per chunk (no auto-commit reuse)`, async (_t) => { + // A shared transaction would auto-commit between async chunks and throw + // TransactionInactiveError. Assert each write gets its own transaction. + let transactions = 0; + const records = [ + { id: 1, value: "a" }, + { id: 2, value: "b" }, + { id: 3, value: "c" }, + ]; + const db = makeDb([], { + onTransaction: () => { + transactions += 1; + }, }); - const { streamToArray } = await import("@datastream/core"); - const output = await streamToArray(stream); - // Should return filtered results (2 items from index), not all 3 from store - strictEqual(output.length, 2); + const writeStream = await webWriteStream({ db, store: "test" }); + for (const record of records) { + writeStream.write(record); + } + writeStream.end(); + await new Promise((resolve) => writeStream.on("finish", resolve)); + + strictEqual(transactions, records.length); }); } diff --git a/packages/indexeddb/index.web.js b/packages/indexeddb/index.web.js index 33c813e..1000a5e 100644 --- a/packages/indexeddb/index.web.js +++ b/packages/indexeddb/index.web.js @@ -9,25 +9,61 @@ export const indexedDBReadStream = async ( { db, store, index, key }, streamOptions = {}, ) => { - let input = db.transaction(store).store; - if (index && key) { - input = input.index(index).iterate(key); + const tx = db.transaction(store); + let source = tx.store; + // A falsy-but-valid index key (0 or "") must still select the index. Only + // treat the index as absent when it is null/undefined, and only treat the + // key as omitted when it is strictly undefined (null is a valid key range). + if (index != null && key !== undefined) { + source = source.index(index).iterate(key); + } else { + source = source.iterate(); } - return createReadableStream(input, streamOptions); + const { signal } = streamOptions; + // idb's async iterators yield IDBCursor objects, not the stored records. + // Map each cursor to its `.value` before handing the source to the core + // stream factory; otherwise consumers receive raw cursors instead of data. + // Honor an AbortSignal by stopping iteration when it fires, and ALWAYS + // remove the listener once iteration settles so a shared signal does not + // accumulate one listener per stream (leak on normal completion). + const records = (async function* () { + let aborted = signal?.aborted ?? false; + let onAbort; + if (signal && !aborted) { + onAbort = () => { + aborted = true; + }; + signal.addEventListener("abort", onAbort, { once: true }); + } + try { + for await (const cursor of source) { + if (aborted) break; + yield cursor.value; + } + } finally { + if (signal && onAbort) { + signal.removeEventListener("abort", onAbort); + onAbort = undefined; + } + } + })(); + return createReadableStream(records, streamOptions); }; export const indexedDBWriteStream = async ( { db, store }, streamOptions = {}, ) => { - const tx = db.transaction(store, "readwrite"); + // A single shared transaction auto-commits as soon as it goes idle between + // async chunks, throwing TransactionInactiveError on the next write. Open a + // fresh readwrite transaction per chunk so each add() runs inside an active + // transaction, and await its completion to preserve ordering/backpressure. const write = async (chunk) => { + const tx = db.transaction(store, "readwrite"); await tx.store.add(chunk); - }; - const final = async () => { await tx.done; }; - return createWritableStream(write, final, streamOptions); + return createWritableStream(write, streamOptions); }; export default { diff --git a/packages/indexeddb/package.json b/packages/indexeddb/package.json index b0c0a59..6e52252 100644 --- a/packages/indexeddb/package.json +++ b/packages/indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@datastream/indexeddb", - "version": "0.5.0", + "version": "0.6.1", "description": "IndexedDB readable and writable streams for browser storage", "type": "module", "engines": { @@ -39,7 +39,8 @@ "scripts": { "test": "npm run test:unit", "test:unit": "node --test", - "test:benchmark": "node __benchmarks__/index.js" + "test:perf": "node --test index.perf.js", + "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run" }, "license": "MIT", "keywords": [ @@ -60,7 +61,7 @@ }, "homepage": "https://datastream.js.org", "dependencies": { - "@datastream/core": "0.5.0", + "@datastream/core": "0.6.1", "idb": "8.0.3" } } diff --git a/packages/ipfs/README.md b/packages/ipfs/README.md index 6c8002d..0a912ba 100644 --- a/packages/ipfs/README.md +++ b/packages/ipfs/README.md @@ -1,6 +1,6 @@IPFS streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/ipfs/index.d.ts b/packages/ipfs/index.d.ts index 8dcd7af..97bd90f 100644 --- a/packages/ipfs/index.d.ts +++ b/packages/ipfs/index.d.ts @@ -29,6 +29,12 @@ export function ipfsAddStream( ): Promise< DatastreamWritable & { result: () => StreamResultJSON and NDJSON (JSON Lines) parsing and formatting transform streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/json/index.d.ts b/packages/json/index.d.ts index e223ced..2e2b9b8 100644 --- a/packages/json/index.d.ts +++ b/packages/json/index.d.ts @@ -25,7 +25,6 @@ export function ndjsonParseStream( export function ndjsonFormatStream( options?: { space?: number | string; - resultKey?: string; }, streamOptions?: StreamOptions, ): DatastreamTransform` guard (space/tab/LF/CR). ---
+const whitespaceLeadCases = {
+ space: "[ 123]",
+ tab: "[\t\t\t\t123]",
+ "line feed": "[\n\n\n\n123]",
+ "carriage return": "[\r\r\r\r123]",
+};
+for (const [name, input] of Object.entries(whitespaceLeadCases)) {
+ test(`${variant}: jsonParseStream skips leading ${name} so raw value stays within maxValueSize`, async (_t) => {
+ // trimmed "123" = 3 chars (<= 5); padded " 123" = 7 chars (> 5).
+ const streams = [
+ createReadableStream(input),
+ jsonParseStream({ maxValueSize: 5 }),
+ ];
+ const output = await streamToArray(pipejoin(streams));
+ deepStrictEqual(output, [123]);
+ });
+}
+
+// --- scanner buffer trim: after each fully-consumed element the processed
+// portion must be trimmed from the buffer so it does not grow unbounded. With a
+// small maxBufferSize, 50+ tiny elements split across chunks only fit if the
+// buffer is trimmed (kills the `trimFrom` ternary and the unconditional trim
+// being skipped). ---
+test(`${variant}: jsonParseStream trims consumed buffer so many chunks fit a small maxBufferSize`, async (_t) => {
+ const chunks = ["["];
+ for (let i = 0; i < 50; i++) chunks.push("1,");
+ chunks.push("1]");
+ const parse = jsonParseStream({ maxBufferSize: 10 });
+ const output = await streamToArray(
+ pipejoin([createReadableStream(chunks), parse]),
+ );
+ deepStrictEqual(output.length, 51);
+ deepStrictEqual(output[0], 1);
+ const { value } = parse.result();
+ deepStrictEqual(value.ParseError, undefined);
+});
+
+// --- scanner trim: an in-progress element must NOT be trimmed away. When the
+// buffer ends mid-element across a chunk boundary, trimFrom must be elementStart
+// (not scanPos), so the partial element is preserved for the next chunk. ---
+test(`${variant}: jsonParseStream preserves an in-progress element across a chunk boundary`, async (_t) => {
+ const parse = jsonParseStream();
+ const streams = [createReadableStream(['[{"abc":', "123}]"]), parse];
+ const output = await streamToArray(pipejoin(streams));
+ deepStrictEqual(output, [{ abc: 123 }]);
+ const { value } = parse.result();
+ deepStrictEqual(value.ParseError, undefined);
+});
+
+// --- scanner trim: after a chunk ends exactly on a comma (no element in
+// progress, buffer fully consumed), the next chunk's leading whitespace must
+// still be skipped. If the trim incorrectly left elementStart pointing at 0,
+// the next element's raw text would absorb that leading whitespace and overflow
+// maxValueSize. Pins the `if (elementStart !== -1)` trim-reset guard. ---
+test(`${variant}: jsonParseStream keeps elementStart unset across a comma-aligned chunk boundary`, async (_t) => {
+ const streams = [
+ createReadableStream(["[1,", " 123]"]),
+ jsonParseStream({ maxValueSize: 4 }),
+ ];
+ const output = await streamToArray(pipejoin(streams));
+ deepStrictEqual(output, [1, 123]);
+});
+
+// --- flush: a lone closing bracket left in the buffer must NOT be emitted as an
+// element (kills `trimmed !== "]"` -> `!== ""`). Construct a stream where the
+// final buffer is exactly "]". ---
+test(`${variant}: jsonParseStream flush ignores a trailing lone closing bracket`, async (_t) => {
+ // "[1" is scanned (element 1 in progress -> emitted at flush); a separate
+ // trailing "]" chunk arrives but, with the array still "open" at depth 0 and
+ // element 1 already pending, exercises the flush `]` guard.
+ const parse = jsonParseStream();
+ const streams = [createReadableStream(["[", "1", "]"]), parse];
+ const output = await streamToArray(pipejoin(streams));
+ deepStrictEqual(output, [1]);
+ const { value } = parse.result();
+ deepStrictEqual(value.ParseError, undefined);
+});
diff --git a/packages/json/package.json b/packages/json/package.json
index f14b946..c05c577 100644
--- a/packages/json/package.json
+++ b/packages/json/package.json
@@ -1,6 +1,6 @@
{
"name": "@datastream/json",
- "version": "0.5.0",
+ "version": "0.6.1",
"description": "JSON and NDJSON (JSON Lines) parsing and formatting transform streams",
"type": "module",
"engines": {
@@ -39,7 +39,8 @@
"scripts": {
"test": "npm run test:unit",
"test:unit": "node --test",
- "test:benchmark": "node __benchmarks__/index.js"
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
},
"license": "MIT",
"keywords": [
@@ -63,6 +64,6 @@
},
"homepage": "https://datastream.js.org",
"dependencies": {
- "@datastream/core": "0.5.0"
+ "@datastream/core": "0.6.1"
}
}
diff --git a/packages/kafka/README.md b/packages/kafka/README.md
new file mode 100644
index 0000000..0e6eadf
--- /dev/null
+++ b/packages/kafka/README.md
@@ -0,0 +1,52 @@
+
+ <datastream> `kafka`
+
+ Kafka producer and consumer streams.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You can read the documentation at: https://datastream.js.org
+
+
+
+## Install
+
+To install datastream you can use NPM:
+
+```bash
+npm install --save @datastream/kafka
+```
+
+
+## Documentation and examples
+
+For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org).
+
+
+## Contributing
+
+Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls).
+
+
+## License
+
+Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors).
\ No newline at end of file
diff --git a/packages/kafka/index.d.ts b/packages/kafka/index.d.ts
new file mode 100644
index 0000000..6af7217
--- /dev/null
+++ b/packages/kafka/index.d.ts
@@ -0,0 +1,82 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import type {
+ DatastreamReadable,
+ DatastreamWritable,
+ StreamOptions,
+} from "@datastream/core";
+
+export interface KafkaConnection {
+ kafka: unknown;
+ producer: unknown | null;
+ consumer: unknown | null;
+ disconnect: () => Promise;
+}
+
+export function kafkaConnect(options?: {
+ brokers?: string[];
+ clientId?: string;
+ ssl?: unknown;
+ sasl?: unknown;
+ groupId?: string;
+ /** Pass `false` to skip opening a producer. */
+ producer?: Record | false;
+ consumer?: Record;
+ /** Injectable Kafka constructor; defaults to a lazy `import("kafkajs")`. */
+ Kafka?: new (
+ options: Record,
+ ) => unknown;
+ [key: string]: unknown;
+}): Promise;
+
+export interface KafkaMessage {
+ value: Uint8Array | string;
+ key?: Uint8Array | string;
+ partition?: number;
+ headers?: Record;
+ timestamp?: string;
+ [key: string]: unknown;
+}
+
+export function kafkaProduceStream(
+ options: {
+ producer: unknown;
+ topic: string;
+ batchSize?: number;
+ acks?: number;
+ compression?: number;
+ timeout?: number;
+ },
+ streamOptions?: StreamOptions,
+): Promise>;
+
+export interface KafkaConsumedMessage {
+ topic: string;
+ partition: number;
+ offset: string;
+ key: Uint8Array | null;
+ value: Uint8Array | null;
+ headers: Record;
+ timestamp: string;
+}
+
+export function kafkaConsumeStream(
+ options: {
+ consumer: unknown;
+ topics: string | string[];
+ fromBeginning?: boolean;
+ autoCommit?: boolean;
+ partitionsConsumedConcurrently?: number;
+ signal?: AbortSignal;
+ },
+ streamOptions?: StreamOptions,
+): Promise<
+ DatastreamReadable & { stop: () => Promise }
+>;
+
+declare const _default: {
+ connect: typeof kafkaConnect;
+ produceStream: typeof kafkaProduceStream;
+ consumeStream: typeof kafkaConsumeStream;
+};
+export default _default;
diff --git a/packages/kafka/index.fuzz.js b/packages/kafka/index.fuzz.js
new file mode 100644
index 0000000..0c81f19
--- /dev/null
+++ b/packages/kafka/index.fuzz.js
@@ -0,0 +1,326 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import test from "node:test";
+import { streamToArray } from "@datastream/core";
+import {
+ kafkaConnect,
+ kafkaConsumeStream,
+ kafkaProduceStream,
+} from "@datastream/kafka";
+import fc from "fast-check";
+
+const catchError = (input, e) => {
+ const expectedErrors = [
+ "kafkaProduceStream: producer required",
+ "kafkaProduceStream: topic required",
+ "kafkaProduceStream: batchSize must be >= 1",
+ "kafkaConsumeStream: consumer required",
+ "kafkaConsumeStream: topics required",
+ // Node.js stream protocol: null is the EOF sentinel and cannot be written
+ "May not write null values to stream",
+ ];
+ // Also swallow normalizeMessage TypeErrors (chunk type errors)
+ if (expectedErrors.includes(e.message)) {
+ return;
+ }
+ if (e instanceof TypeError && e.message.startsWith("kafkaProduceStream:")) {
+ return;
+ }
+ // Node.js writable stream rejects null with ERR_STREAM_NULL_VALUES
+ if (e.code === "ERR_STREAM_NULL_VALUES") {
+ return;
+ }
+ console.error(input, e);
+ throw e;
+};
+
+// ---------------------------------------------------------------------------
+// Minimal fake Kafka factory
+// ---------------------------------------------------------------------------
+
+const makeFakeKafka = () => {
+ const producerMock = {
+ connect: async () => {},
+ disconnect: async () => {},
+ send: async () => {},
+ };
+
+ const consumerMock = {
+ _runResolve: null,
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run: async () =>
+ new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ }),
+ };
+
+ const Kafka = function (opts) {
+ this.opts = opts;
+ this.producer = () => producerMock;
+ this.consumer = () => consumerMock;
+ };
+
+ return { Kafka, producerMock, consumerMock };
+};
+
+// ---------------------------------------------------------------------------
+// fuzz: kafkaConnect
+// ---------------------------------------------------------------------------
+
+test("fuzz kafkaConnect w/ random options", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.record({
+ brokers: fc.option(
+ fc.array(fc.string({ minLength: 1, maxLength: 50 }), {
+ minLength: 1,
+ maxLength: 3,
+ }),
+ ),
+ clientId: fc.option(fc.string({ minLength: 0, maxLength: 50 })),
+ groupId: fc.option(fc.string({ minLength: 0, maxLength: 50 })),
+ ssl: fc.option(fc.boolean()),
+ producer: fc.option(fc.oneof(fc.constant(false), fc.constant({}))),
+ }),
+ async (options) => {
+ try {
+ const { Kafka } = makeFakeKafka();
+ const conn = await kafkaConnect({ ...options, Kafka });
+ await conn.disconnect();
+ } catch (e) {
+ catchError(options, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// fuzz: kafkaProduceStream options
+// ---------------------------------------------------------------------------
+
+test("fuzz kafkaProduceStream w/ random options", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.record({
+ topic: fc.option(fc.string({ minLength: 0, maxLength: 50 })),
+ batchSize: fc.option(fc.integer({ min: -5, max: 200 })),
+ acks: fc.option(fc.integer({ min: -1, max: 1 })),
+ compression: fc.option(fc.integer({ min: 0, max: 4 })),
+ timeout: fc.option(fc.integer({ min: 0, max: 30_000 })),
+ }),
+ async (options) => {
+ try {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ ...options,
+ });
+ stream.end();
+ await new Promise((resolve, reject) => {
+ stream.on("finish", resolve);
+ stream.on("error", reject);
+ });
+ } catch (e) {
+ catchError(options, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// fuzz: kafkaProduceStream w/ random messages
+// ---------------------------------------------------------------------------
+
+test("fuzz kafkaProduceStream w/ random messages", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(
+ fc.oneof(
+ fc.string({ minLength: 0, maxLength: 100 }),
+ fc.record({
+ value: fc.string({ minLength: 0, maxLength: 100 }),
+ key: fc.option(fc.string({ minLength: 0, maxLength: 50 })),
+ }),
+ // invalid types to exercise normalizeMessage error path
+ fc.integer(),
+ fc.boolean(),
+ // null is excluded: it is the Node.js stream EOF sentinel and
+ // writing null to a Writable is a stream protocol error, not
+ // something kafkaProduceStream is expected to handle.
+ ),
+ { minLength: 1, maxLength: 50 },
+ ),
+ async (messages) => {
+ try {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "fuzz-topic",
+ });
+ await new Promise((resolve, reject) => {
+ stream.on("error", (e) => {
+ try {
+ catchError(messages, e);
+ resolve();
+ } catch (err) {
+ reject(err);
+ }
+ });
+ stream.on("finish", resolve);
+ for (const msg of messages) {
+ stream.write(msg);
+ }
+ stream.end();
+ });
+ } catch (e) {
+ catchError(messages, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// fuzz: kafkaConsumeStream options
+// ---------------------------------------------------------------------------
+
+test("fuzz kafkaConsumeStream w/ random options", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.record({
+ topics: fc.option(
+ fc.oneof(
+ fc.string({ minLength: 1, maxLength: 50 }),
+ fc.array(fc.string({ minLength: 1, maxLength: 50 }), {
+ minLength: 1,
+ maxLength: 3,
+ }),
+ fc.constant([]),
+ ),
+ ),
+ fromBeginning: fc.option(fc.boolean()),
+ autoCommit: fc.option(fc.boolean()),
+ partitionsConsumedConcurrently: fc.option(
+ fc.integer({ min: 1, max: 8 }),
+ ),
+ }),
+ async (options) => {
+ try {
+ const { consumerMock } = makeFakeKafka();
+ const stream = await kafkaConsumeStream({
+ consumer: consumerMock,
+ ...options,
+ });
+ const closePromise = new Promise((resolve) =>
+ stream.once("close", resolve),
+ );
+ stream.destroy();
+ await closePromise;
+ } catch (e) {
+ catchError(options, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// fuzz: kafkaConsumeStream message delivery w/ random message payloads
+// ---------------------------------------------------------------------------
+
+test("fuzz kafkaConsumeStream w/ random message payloads", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(
+ fc.record({
+ topic: fc.string({ minLength: 1, maxLength: 50 }),
+ partition: fc.integer({ min: 0, max: 10 }),
+ offset: fc.nat().map(String),
+ key: fc.option(fc.string({ minLength: 0, maxLength: 50 })),
+ value: fc.option(fc.string({ minLength: 0, maxLength: 200 })),
+ timestamp: fc.nat().map(String),
+ }),
+ { minLength: 1, maxLength: 100 },
+ ),
+ async (messages) => {
+ try {
+ let eachMessageFn;
+ const consumerMock = {
+ _runResolve: null,
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run: async ({ eachMessage }) => {
+ eachMessageFn = eachMessage;
+ return new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ });
+ },
+ };
+
+ const stream = await kafkaConsumeStream(
+ { consumer: consumerMock, topics: "fuzz-topic" },
+ { highWaterMark: messages.length + 1 },
+ );
+
+ const delivery = (async () => {
+ for (const msg of messages) {
+ await eachMessageFn({
+ topic: msg.topic,
+ partition: msg.partition,
+ message: {
+ offset: msg.offset,
+ key: msg.key,
+ value: msg.value,
+ headers: {},
+ timestamp: msg.timestamp,
+ },
+ });
+ }
+ await stream.stop();
+ })();
+
+ await streamToArray(stream);
+ await delivery;
+ } catch (e) {
+ catchError(messages, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
diff --git a/packages/kafka/index.node.js b/packages/kafka/index.node.js
new file mode 100644
index 0000000..ac3dbd7
--- /dev/null
+++ b/packages/kafka/index.node.js
@@ -0,0 +1,271 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import { Readable } from "node:stream";
+import { createWritableStream } from "@datastream/core";
+
+export const kafkaConnect = async ({
+ brokers,
+ clientId,
+ ssl,
+ sasl,
+ groupId,
+ producer: producerOptions,
+ consumer: consumerOptions,
+ // Injectable Kafka constructor (defaults to kafkajs). Lets callers/tests
+ // supply a stub without reaching for a real broker; falls back to the lazy
+ // dynamic import so the production path is unchanged.
+ Kafka,
+ ...kafkaOptions
+} = {}) => {
+ Kafka ??= (await import("kafkajs")).Kafka;
+ const kafka = new Kafka({ brokers, clientId, ssl, sasl, ...kafkaOptions });
+ // Open a producer only if it wasn't explicitly disabled. Open a consumer
+ // only when a groupId is provided. Either or both may be null.
+ const producer =
+ producerOptions === false ? null : kafka.producer(producerOptions ?? {});
+ const consumer = groupId
+ ? kafka.consumer({ groupId, ...(consumerOptions ?? {}) })
+ : null;
+ // Connect under a guard: if the second connect fails, the first is already
+ // connected (open socket + background metadata/heartbeat timers) with no
+ // disconnect handle escaping to the caller. Tear down whatever connected
+ // before rethrowing so the error path leaks nothing.
+ try {
+ if (producer) await producer.connect();
+ if (consumer) await consumer.connect();
+ } catch (err) {
+ await producer?.disconnect().catch(() => {});
+ await consumer?.disconnect().catch(() => {});
+ throw err;
+ }
+ return {
+ kafka,
+ producer,
+ consumer,
+ disconnect: async () => {
+ if (producer) await producer.disconnect();
+ if (consumer) await consumer.disconnect();
+ },
+ };
+};
+
+const normalizeMessage = (chunk) => {
+ if (chunk instanceof Uint8Array || typeof chunk === "string") {
+ return { value: chunk };
+ }
+ if (chunk && typeof chunk === "object" && "value" in chunk) {
+ return chunk;
+ }
+ // Note: `chunk` is never null here — Node.js Writable streams reject null
+ // with ERR_STREAM_NULL_VALUES before the write callback fires — so we report
+ // `typeof chunk` directly without a dead `chunk === null` branch.
+ throw new TypeError(
+ `kafkaProduceStream: chunk must be Uint8Array, string, or { value, ... } object (got ${typeof chunk})`,
+ );
+};
+
+export const kafkaProduceStream = async (
+ {
+ producer,
+ topic,
+ batchSize = 100,
+ acks = -1,
+ compression,
+ timeout: requestTimeout,
+ } = {},
+ streamOptions = {},
+) => {
+ if (!producer) throw new TypeError("kafkaProduceStream: producer required");
+ if (!topic) throw new TypeError("kafkaProduceStream: topic required");
+ if (!(batchSize >= 1)) {
+ throw new TypeError("kafkaProduceStream: batchSize must be >= 1");
+ }
+
+ let batch = [];
+ const flush = async () => {
+ if (!batch.length) return;
+ // Snapshot then start a fresh batch *before* the send. Two reasons:
+ // 1. The snapshot stays addressable on the error path so callers can
+ // re-queue via `err.failedMessages` instead of losing the batch.
+ // 2. If kafkajs mutates `messages` (or holds a reference), we don't want
+ // our next write() to interleave with what's in flight.
+ const sending = batch;
+ batch = [];
+ try {
+ await producer.send({
+ topic,
+ messages: sending,
+ acks,
+ compression,
+ timeout: requestTimeout,
+ });
+ } catch (err) {
+ err.failedMessages = sending;
+ throw err;
+ }
+ };
+ const write = async (chunk) => {
+ batch.push(normalizeMessage(chunk));
+ if (batch.length >= batchSize) await flush();
+ };
+ const final = async () => {
+ await flush();
+ };
+ const stream = createWritableStream(write, final, streamOptions);
+ // Node's final() runs only on a graceful end(), never on destroy() or an
+ // AbortSignal-triggered teardown. Without this, a partial batch buffered
+ // below batchSize would be silently dropped on abort — no send, no error.
+ // Surface the un-sent batch on the teardown error (mirroring flush()'s
+ // err.failedMessages contract) so callers can recover/retry it instead of
+ // losing it. We wrap the default Writable destroy rather than replacing it.
+ const originalDestroy = stream._destroy.bind(stream);
+ stream._destroy = (err, cb) => {
+ if (batch.length) {
+ // The stream is being torn down: no further write()/final() can run, so
+ // `batch` is never read again. Hand the buffered messages straight to the
+ // error (mirroring flush()'s err.failedMessages contract) without a dead
+ // reassignment.
+ err ??= new Error("kafkaProduceStream: destroyed with a buffered batch");
+ err.failedMessages = batch;
+ }
+ originalDestroy(err, cb);
+ };
+ return stream;
+};
+
+export const kafkaConsumeStream = async (
+ {
+ consumer,
+ topics,
+ fromBeginning = false,
+ autoCommit = true,
+ partitionsConsumedConcurrently = 1,
+ signal,
+ },
+ streamOptions = {},
+) => {
+ if (!consumer) throw new TypeError("kafkaConsumeStream: consumer required");
+ if (!topics || (Array.isArray(topics) && !topics.length)) {
+ throw new TypeError("kafkaConsumeStream: topics required");
+ }
+
+ const topicList = Array.isArray(topics) ? topics : [topics];
+ for (const topic of topicList) {
+ await consumer.subscribe({ topic, fromBeginning });
+ }
+
+ // Backpressure: kafkajs awaits each eachMessage callback before fetching the
+ // next message on the same partition (and per-partition concurrency caps
+ // fan-in). We construct a Node Readable and let `.push` return false when
+ // the buffer is full; we then await the next `_read` (consumer pulled) before
+ // returning from eachMessage. That suspends kafkajs's loop end-to-end so
+ // backpressure reaches the broker instead of bursting through.
+ // With partitionsConsumedConcurrently > 1, several eachMessage callbacks can
+ // be parked on backpressure at the same time. Track every waiting resolver
+ // (not just the latest) and release them all on the next `_read`; a single
+ // slot would drop all but the last waiter and hang those partition loops.
+ const readResolvers = new Set();
+ const waitForRead = () =>
+ new Promise((resolve) => {
+ readResolvers.add(resolve);
+ });
+ const releaseReaders = () => {
+ // Snapshot then clear before resolving so a resolver that synchronously
+ // re-parks lands in a fresh Set. Iterating an empty Set is already a no-op,
+ // so no explicit size guard is needed.
+ const pending = [...readResolvers];
+ readResolvers.clear();
+ for (const resolve of pending) resolve();
+ };
+
+ let stopped = false;
+ let runPromise;
+ // Memoize consumer.stop() so every teardown path (explicit stop(), destroy(),
+ // abort) shares ONE invocation. kafkajs's consumer.stop() is not guaranteed
+ // safe to call twice concurrently, and stop()+later-destroy() previously
+ // called it twice. Best-effort: swallow errors, run at most once.
+ let consumerStopPromise;
+ const stopConsumerOnce = () => {
+ consumerStopPromise ??= Promise.resolve()
+ .then(() => consumer.stop())
+ .catch(() => {});
+ return consumerStopPromise;
+ };
+
+ const stream = new Readable({
+ objectMode: true,
+ highWaterMark: streamOptions.highWaterMark ?? 100,
+ read() {
+ releaseReaders();
+ },
+ destroy(err, cb) {
+ stopped = true;
+ // Drop the abort listener: a destroyed stream never needs to react to
+ // the signal again, and on long-lived/shared signals an un-removed
+ // listener accumulates per stream.
+ if (signal) signal.removeEventListener("abort", stop);
+ // Release any eachMessage callbacks parked on backpressure first, so
+ // they observe `stopped` and return. consumer.stop() waits for in-flight
+ // callbacks to settle; without this release it would wait on a parked
+ // callback that itself can only resume once `_read` fires — a circular
+ // wait that deadlocks. Symmetric with stop().
+ releaseReaders();
+ // Best-effort consumer.stop on destroy, memoized so a prior stop() (or
+ // a concurrent abort) does not trigger a second consumer.stop().
+ stopConsumerOnce().then(() => cb(err));
+ },
+ });
+
+ runPromise = consumer.run({
+ autoCommit,
+ partitionsConsumedConcurrently,
+ eachMessage: async ({ topic, partition, message }) => {
+ if (stopped) return;
+ const wantsMore = stream.push({
+ topic,
+ partition,
+ offset: message.offset,
+ key: message.key,
+ value: message.value,
+ headers: message.headers,
+ timestamp: message.timestamp,
+ });
+ if (!wantsMore) await waitForRead();
+ },
+ });
+
+ const stop = async () => {
+ // No early-out on a repeat call is needed: every step below is idempotent —
+ // consumer.stop() is memoized via stopConsumerOnce(), removeEventListener is
+ // a no-op once removed, stream.push(null) is a no-op after EOF, and
+ // releaseReaders() is a no-op with no parked readers. Setting `stopped`
+ // first still makes parked eachMessage callbacks observe it and return.
+ stopped = true;
+ // Detach from the signal on every normal termination. `{ once: true }`
+ // only fires-and-removes when the signal actually aborts; for a normal
+ // stop()/drain the listener would otherwise linger forever on a
+ // long-lived/shared signal, accumulating one listener per consume stream.
+ // `stop` is the exact handler reference, so this removes cleanly.
+ if (signal) signal.removeEventListener("abort", stop);
+ await stopConsumerOnce();
+ stream.push(null);
+ await runPromise.catch(() => {});
+ // Unblock any pending eachMessage awaits so they can observe `stopped`
+ // and return.
+ releaseReaders();
+ };
+
+ if (signal) {
+ if (signal.aborted) await stop();
+ else signal.addEventListener("abort", stop);
+ }
+
+ stream.stop = stop;
+ return stream;
+};
+
+export default {
+ connect: kafkaConnect,
+ produceStream: kafkaProduceStream,
+ consumeStream: kafkaConsumeStream,
+};
diff --git a/packages/kafka/index.perf.js b/packages/kafka/index.perf.js
new file mode 100644
index 0000000..3f6901f
--- /dev/null
+++ b/packages/kafka/index.perf.js
@@ -0,0 +1,228 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import test from "node:test";
+import { createReadableStream, streamToArray } from "@datastream/core";
+import {
+ kafkaConnect,
+ kafkaConsumeStream,
+ kafkaProduceStream,
+} from "@datastream/kafka";
+import { Bench } from "tinybench";
+
+const time = Number(process.env.BENCH_TIME ?? 5_000);
+
+// ---------------------------------------------------------------------------
+// Fake Kafka factory (trimmed — no spy counters needed for perf)
+// ---------------------------------------------------------------------------
+
+const makeFakeKafka = ({ run } = {}) => {
+ const producerMock = {
+ connect: async () => {},
+ disconnect: async () => {},
+ send: async () => {},
+ };
+
+ const consumerMock = {
+ _runResolve: null,
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run:
+ run ??
+ (async () =>
+ new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ })),
+ };
+
+ const Kafka = function (opts) {
+ this.opts = opts;
+ this.producer = () => producerMock;
+ this.consumer = () => consumerMock;
+ };
+
+ return { Kafka, producerMock, consumerMock };
+};
+
+// ---------------------------------------------------------------------------
+// perf: kafkaProduceStream
+// ---------------------------------------------------------------------------
+
+test("perf: kafkaProduceStream", async () => {
+ const bench = new Bench({ name: "kafkaProduceStream", time });
+
+ const MSG_COUNT = 10_000;
+ const messages = Array.from({ length: MSG_COUNT }, (_, i) => ({
+ value: `message-${i}`,
+ key: `key-${i}`,
+ }));
+
+ bench.add(`produce ${MSG_COUNT} messages (batchSize default)`, async () => {
+ const { Kafka } = makeFakeKafka();
+ const { producer } = await kafkaConnect({ Kafka });
+ const stream = await kafkaProduceStream({ producer, topic: "perf-topic" });
+ const readable = createReadableStream(messages);
+ const writeAll = new Promise((resolve, reject) => {
+ readable.on("data", (chunk) => {
+ stream.write(chunk);
+ });
+ readable.on("end", () => {
+ stream.end();
+ });
+ stream.on("finish", resolve);
+ stream.on("error", reject);
+ });
+ await writeAll;
+ });
+
+ bench.add(`produce ${MSG_COUNT} messages (batchSize 1000)`, async () => {
+ const { Kafka } = makeFakeKafka();
+ const { producer } = await kafkaConnect({ Kafka });
+ const stream = await kafkaProduceStream({
+ producer,
+ topic: "perf-topic",
+ batchSize: 1_000,
+ });
+ const readable = createReadableStream(messages);
+ const writeAll = new Promise((resolve, reject) => {
+ readable.on("data", (chunk) => {
+ stream.write(chunk);
+ });
+ readable.on("end", () => {
+ stream.end();
+ });
+ stream.on("finish", resolve);
+ stream.on("error", reject);
+ });
+ await writeAll;
+ });
+
+ bench.add(`produce ${MSG_COUNT} string messages`, async () => {
+ const { Kafka } = makeFakeKafka();
+ const { producer } = await kafkaConnect({ Kafka });
+ const stream = await kafkaProduceStream({ producer, topic: "perf-topic" });
+ const strMessages = Array.from({ length: MSG_COUNT }, (_, i) => `msg-${i}`);
+ const readable = createReadableStream(strMessages);
+ const writeAll = new Promise((resolve, reject) => {
+ readable.on("data", (chunk) => {
+ stream.write(chunk);
+ });
+ readable.on("end", () => {
+ stream.end();
+ });
+ stream.on("finish", resolve);
+ stream.on("error", reject);
+ });
+ await writeAll;
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+// ---------------------------------------------------------------------------
+// perf: kafkaConsumeStream
+// ---------------------------------------------------------------------------
+
+test("perf: kafkaConsumeStream", async () => {
+ const bench = new Bench({ name: "kafkaConsumeStream", time });
+
+ const MSG_COUNT = 10_000;
+
+ bench.add(`consume ${MSG_COUNT} messages`, async () => {
+ let eachMessageFn;
+ const consumerMock = {
+ _runResolve: null,
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run: async ({ eachMessage }) => {
+ eachMessageFn = eachMessage;
+ return new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ });
+ },
+ };
+
+ const stream = await kafkaConsumeStream(
+ { consumer: consumerMock, topics: "perf-topic" },
+ { highWaterMark: MSG_COUNT },
+ );
+
+ // Drive messages then stop
+ const delivery = (async () => {
+ for (let i = 0; i < MSG_COUNT; i++) {
+ await eachMessageFn({
+ topic: "perf-topic",
+ partition: 0,
+ message: {
+ offset: String(i),
+ key: `key-${i}`,
+ value: `value-${i}`,
+ headers: {},
+ timestamp: String(Date.now()),
+ },
+ });
+ }
+ await stream.stop();
+ })();
+
+ await streamToArray(stream);
+ await delivery;
+ });
+
+ bench.add(`consume ${MSG_COUNT} messages (highWaterMark 100)`, async () => {
+ let eachMessageFn;
+ const consumerMock = {
+ _runResolve: null,
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run: async ({ eachMessage }) => {
+ eachMessageFn = eachMessage;
+ return new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ });
+ },
+ };
+
+ const stream = await kafkaConsumeStream(
+ { consumer: consumerMock, topics: "perf-topic" },
+ { highWaterMark: 100 },
+ );
+
+ const delivery = (async () => {
+ for (let i = 0; i < MSG_COUNT; i++) {
+ await eachMessageFn({
+ topic: "perf-topic",
+ partition: 0,
+ message: {
+ offset: String(i),
+ key: `key-${i}`,
+ value: `value-${i}`,
+ headers: {},
+ timestamp: String(Date.now()),
+ },
+ });
+ }
+ await stream.stop();
+ })();
+
+ await streamToArray(stream);
+ await delivery;
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
diff --git a/packages/kafka/index.test.js b/packages/kafka/index.test.js
new file mode 100644
index 0000000..ae4eb1b
--- /dev/null
+++ b/packages/kafka/index.test.js
@@ -0,0 +1,1596 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert";
+import test from "node:test";
+import { streamToArray } from "@datastream/core";
+
+import kafkaDefault, {
+ kafkaConnect,
+ kafkaConsumeStream,
+ kafkaProduceStream,
+} from "@datastream/kafka";
+
+// ---------------------------------------------------------------------------
+// Fake Kafka factory helpers
+// ---------------------------------------------------------------------------
+
+/** Build a simple fake kafkajs-like Kafka constructor. */
+const makeFakeKafka = ({
+ connectProducer = async () => {},
+ disconnectProducer = async () => {},
+ sendMessages = async () => {},
+ connectConsumer = async () => {},
+ disconnectConsumer = async () => {},
+ subscribe = async () => {},
+ // run: caller can override; default drives one message then resolves
+ run,
+} = {}) => {
+ const producerMock = {
+ connectCalled: 0,
+ disconnectCalled: 0,
+ sendArgs: [],
+ connect: async () => {
+ producerMock.connectCalled++;
+ return connectProducer();
+ },
+ disconnect: async () => {
+ producerMock.disconnectCalled++;
+ return disconnectProducer();
+ },
+ send: async (opts) => {
+ producerMock.sendArgs.push(opts);
+ return sendMessages(opts);
+ },
+ };
+
+ const consumerMock = {
+ connectCalled: 0,
+ disconnectCalled: 0,
+ subscribeCalls: [],
+ stopCalled: 0,
+ _runResolve: null,
+ connect: async () => {
+ consumerMock.connectCalled++;
+ return connectConsumer();
+ },
+ disconnect: async () => {
+ consumerMock.disconnectCalled++;
+ return disconnectConsumer();
+ },
+ subscribe: async (opts) => {
+ consumerMock.subscribeCalls.push(opts);
+ return subscribe(opts);
+ },
+ stop: async () => {
+ consumerMock.stopCalled++;
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run:
+ run ??
+ (async () => {
+ // Return a promise that resolves when stop() is called
+ return new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ });
+ }),
+ };
+
+ const Kafka = function (opts) {
+ this.opts = opts;
+ this.producer = (opts) => {
+ producerMock.producerOpts = opts;
+ return producerMock;
+ };
+ this.consumer = (opts) => {
+ consumerMock.consumerOpts = opts;
+ return consumerMock;
+ };
+ };
+
+ return { Kafka, producerMock, consumerMock };
+};
+
+// ---------------------------------------------------------------------------
+// kafkaConnect
+// ---------------------------------------------------------------------------
+
+test("kafkaConnect: returns kafka, producer, consumer, disconnect", async () => {
+ const { Kafka, producerMock, consumerMock } = makeFakeKafka();
+ const result = await kafkaConnect({ groupId: "g1", Kafka });
+ ok(result.kafka, "has kafka");
+ ok(result.producer === producerMock, "has producer");
+ ok(result.consumer === consumerMock, "has consumer");
+ ok(typeof result.disconnect === "function", "has disconnect");
+});
+
+test("kafkaConnect: passes brokers/clientId/ssl/sasl to Kafka constructor", async () => {
+ let capturedOpts;
+ const FakeKafka = function (opts) {
+ capturedOpts = opts;
+ this.producer = () => ({
+ connect: async () => {},
+ disconnect: async () => {},
+ });
+ this.consumer = () => ({
+ connect: async () => {},
+ disconnect: async () => {},
+ });
+ };
+ await kafkaConnect({
+ brokers: ["b1"],
+ clientId: "cid",
+ ssl: true,
+ sasl: { mechanism: "plain" },
+ groupId: "g1",
+ Kafka: FakeKafka,
+ });
+ deepStrictEqual(capturedOpts.brokers, ["b1"]);
+ strictEqual(capturedOpts.clientId, "cid");
+ strictEqual(capturedOpts.ssl, true);
+ deepStrictEqual(capturedOpts.sasl, { mechanism: "plain" });
+});
+
+test("kafkaConnect: producer is null when producerOptions === false", async () => {
+ const { Kafka } = makeFakeKafka();
+ const result = await kafkaConnect({ producer: false, Kafka });
+ strictEqual(result.producer, null, "producer should be null");
+});
+
+test("kafkaConnect: consumer is null when groupId is not provided", async () => {
+ const { Kafka } = makeFakeKafka();
+ const result = await kafkaConnect({ Kafka });
+ strictEqual(result.consumer, null, "consumer should be null");
+});
+
+test("kafkaConnect: producer connects when producerOptions is truthy", async () => {
+ const { Kafka, producerMock } = makeFakeKafka();
+ await kafkaConnect({ Kafka });
+ strictEqual(producerMock.connectCalled, 1, "producer.connect called once");
+});
+
+test("kafkaConnect: consumer connects when groupId is provided", async () => {
+ const { Kafka, consumerMock } = makeFakeKafka();
+ await kafkaConnect({ groupId: "g1", Kafka });
+ strictEqual(consumerMock.connectCalled, 1, "consumer.connect called once");
+});
+
+test("kafkaConnect: producer not connected when producer === false", async () => {
+ const { Kafka, producerMock } = makeFakeKafka();
+ await kafkaConnect({ producer: false, Kafka });
+ strictEqual(producerMock.connectCalled, 0, "producer.connect not called");
+});
+
+test("kafkaConnect: consumer not connected when no groupId", async () => {
+ const { Kafka, consumerMock } = makeFakeKafka();
+ await kafkaConnect({ Kafka });
+ strictEqual(consumerMock.connectCalled, 0, "consumer.connect not called");
+});
+
+test("kafkaConnect: disconnect() calls producer.disconnect when producer exists", async () => {
+ const { Kafka, producerMock } = makeFakeKafka();
+ const conn = await kafkaConnect({ Kafka });
+ await conn.disconnect();
+ strictEqual(producerMock.disconnectCalled, 1);
+});
+
+test("kafkaConnect: disconnect() calls consumer.disconnect when consumer exists", async () => {
+ const { Kafka, consumerMock } = makeFakeKafka();
+ const conn = await kafkaConnect({ groupId: "g1", Kafka });
+ await conn.disconnect();
+ strictEqual(consumerMock.disconnectCalled, 1);
+});
+
+test("kafkaConnect: disconnect() does not call producer.disconnect when producer is null", async () => {
+ const { Kafka, producerMock } = makeFakeKafka();
+ const conn = await kafkaConnect({ producer: false, Kafka });
+ await conn.disconnect();
+ strictEqual(producerMock.disconnectCalled, 0);
+});
+
+test("kafkaConnect: disconnect() does not call consumer.disconnect when consumer is null", async () => {
+ const { Kafka, consumerMock } = makeFakeKafka();
+ const conn = await kafkaConnect({ Kafka });
+ await conn.disconnect();
+ strictEqual(consumerMock.disconnectCalled, 0);
+});
+
+test("kafkaConnect: producer connect error triggers teardown and rethrows", async () => {
+ const { Kafka, producerMock } = makeFakeKafka({
+ connectProducer: async () => {
+ throw new Error("producer connect failed");
+ },
+ });
+ await rejects(async () => kafkaConnect({ Kafka }), /producer connect failed/);
+ strictEqual(producerMock.disconnectCalled, 1);
+});
+
+test("kafkaConnect: consumer connect error triggers teardown and rethrows", async () => {
+ const { Kafka, producerMock, consumerMock } = makeFakeKafka({
+ connectConsumer: async () => {
+ throw new Error("consumer connect failed");
+ },
+ });
+ await rejects(
+ async () => kafkaConnect({ groupId: "g1", Kafka }),
+ /consumer connect failed/,
+ );
+ strictEqual(producerMock.disconnectCalled, 1);
+ strictEqual(consumerMock.disconnectCalled, 1);
+});
+
+test("kafkaConnect: passes consumerOptions alongside groupId", async () => {
+ const { Kafka, consumerMock } = makeFakeKafka();
+ await kafkaConnect({
+ groupId: "g1",
+ consumer: { sessionTimeout: 5000 },
+ Kafka,
+ });
+ deepStrictEqual(consumerMock.consumerOpts, {
+ groupId: "g1",
+ sessionTimeout: 5000,
+ });
+});
+
+test("kafkaConnect: default exports connect/produceStream/consumeStream", () => {
+ ok(typeof kafkaDefault.connect === "function");
+ ok(typeof kafkaDefault.produceStream === "function");
+ ok(typeof kafkaDefault.consumeStream === "function");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: validation
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: throws when producer is missing", async () => {
+ await rejects(
+ async () => kafkaProduceStream({ topic: "t" }),
+ /kafkaProduceStream: producer required/,
+ );
+});
+
+test("kafkaProduceStream: throws TypeError when producer is missing", async () => {
+ await rejects(async () => kafkaProduceStream({ topic: "t" }), TypeError);
+});
+
+test("kafkaProduceStream: throws when topic is missing", async () => {
+ const { producerMock } = makeFakeKafka();
+ await rejects(
+ async () => kafkaProduceStream({ producer: producerMock }),
+ /kafkaProduceStream: topic required/,
+ );
+});
+
+test("kafkaProduceStream: throws TypeError when topic is missing", async () => {
+ const { producerMock } = makeFakeKafka();
+ await rejects(
+ async () => kafkaProduceStream({ producer: producerMock }),
+ TypeError,
+ );
+});
+
+test("kafkaProduceStream: throws when batchSize is 0", async () => {
+ const { producerMock } = makeFakeKafka();
+ await rejects(
+ async () =>
+ kafkaProduceStream({ producer: producerMock, topic: "t", batchSize: 0 }),
+ /kafkaProduceStream: batchSize must be >= 1/,
+ );
+});
+
+test("kafkaProduceStream: throws TypeError when batchSize < 1", async () => {
+ const { producerMock } = makeFakeKafka();
+ await rejects(
+ async () =>
+ kafkaProduceStream({ producer: producerMock, topic: "t", batchSize: 0 }),
+ TypeError,
+ );
+});
+
+test("kafkaProduceStream: accepts batchSize === 1 without throwing", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 1,
+ });
+ ok(stream, "stream created");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: normalizeMessage
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: sends string chunk wrapped as { value }", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ stream.write("hello");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs.length, 1);
+ deepStrictEqual(producerMock.sendArgs[0].messages[0], { value: "hello" });
+});
+
+test("kafkaProduceStream: sends Uint8Array chunk wrapped as { value }", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const buf = new Uint8Array([1, 2, 3]);
+ stream.write(buf);
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs.length, 1);
+ deepStrictEqual(producerMock.sendArgs[0].messages[0], { value: buf });
+});
+
+test("kafkaProduceStream: passes through { value, key, headers } object unchanged", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const msg = { value: "v", key: "k", headers: { h: "1" } };
+ stream.write(msg);
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ deepStrictEqual(producerMock.sendArgs[0].messages[0], msg);
+});
+
+test("kafkaProduceStream: emits error on invalid chunk (boolean false — not Uint8Array/string/object-with-value)", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const errorPromise = new Promise((resolve) => stream.once("error", resolve));
+ stream.write(false);
+ const err = await errorPromise;
+ ok(err instanceof TypeError, "error is TypeError");
+ ok(err.message.includes("boolean"), "error message includes actual type");
+});
+
+test("kafkaProduceStream: error message includes actual type for non-null invalid chunk", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const errorPromise = new Promise((resolve) => stream.once("error", resolve));
+ stream.write(42);
+ const err = await errorPromise;
+ ok(err.message.includes("number"), "error message includes 'number'");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: batching
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: batches messages and flushes at batchSize", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 3,
+ });
+ for (let i = 0; i < 6; i++) stream.write(`msg${i}`);
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ // 2 batches of 3
+ strictEqual(producerMock.sendArgs.length, 2);
+ strictEqual(producerMock.sendArgs[0].messages.length, 3);
+ strictEqual(producerMock.sendArgs[1].messages.length, 3);
+});
+
+test("kafkaProduceStream: partial batch flushed on end", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 10,
+ });
+ stream.write("a");
+ stream.write("b");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs.length, 1);
+ strictEqual(producerMock.sendArgs[0].messages.length, 2);
+});
+
+test("kafkaProduceStream: empty stream sends no messages", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs.length, 0);
+});
+
+test("kafkaProduceStream: sends with correct topic", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "my-topic",
+ });
+ stream.write("msg");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs[0].topic, "my-topic");
+});
+
+test("kafkaProduceStream: sends with acks default -1", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ stream.write("msg");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs[0].acks, -1);
+});
+
+test("kafkaProduceStream: sends with custom acks value", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ acks: 1,
+ });
+ stream.write("msg");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs[0].acks, 1);
+});
+
+test("kafkaProduceStream: forwards compression to producer.send", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ compression: 2,
+ });
+ stream.write("msg");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs[0].compression, 2);
+});
+
+test("kafkaProduceStream: forwards timeout to producer.send", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ timeout: 5000,
+ });
+ stream.write("msg");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs[0].timeout, 5000);
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: send error attaches failedMessages
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: send error attaches failedMessages", async () => {
+ const sendErr = new Error("send failed");
+ const { producerMock } = makeFakeKafka({
+ sendMessages: async () => {
+ throw sendErr;
+ },
+ });
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const errPromise = new Promise((resolve) => stream.once("error", resolve));
+ stream.write("msg");
+ stream.end();
+ const err = await errPromise;
+ ok(Array.isArray(err.failedMessages), "failedMessages is array");
+ strictEqual(err.failedMessages.length, 1);
+ strictEqual(err.failedMessages[0].value, "msg");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: destroy with buffered batch
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: destroy with buffered batch attaches failedMessages to error", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 100,
+ });
+ const errPromise = new Promise((resolve) => stream.once("error", resolve));
+ // Await both writes so they land in the batch before destroy
+ const w1Done = new Promise((resolve) => {
+ stream.write("msg1", resolve);
+ });
+ const w2Done = new Promise((resolve) => {
+ stream.write("msg2", resolve);
+ });
+ await w1Done;
+ await w2Done;
+ stream.destroy();
+ const err = await errPromise;
+ ok(
+ err.message.includes("destroyed with a buffered batch"),
+ `expected destroy error, got: ${err.message}`,
+ );
+ ok(Array.isArray(err.failedMessages), "failedMessages is array");
+ strictEqual(err.failedMessages.length, 2);
+});
+
+test("kafkaProduceStream: destroy with existing error attaches failedMessages", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 100,
+ });
+ const errPromise = new Promise((resolve) => stream.once("error", resolve));
+ // Await write so msg lands in batch before destroy
+ const wDone = new Promise((resolve) => {
+ stream.write("msg", resolve);
+ });
+ await wDone;
+ const existingErr = new Error("existing error");
+ stream.destroy(existingErr);
+ const err = await errPromise;
+ strictEqual(err.message, "existing error");
+ ok(Array.isArray(err.failedMessages));
+ strictEqual(err.failedMessages.length, 1);
+});
+
+test("kafkaProduceStream: destroy with empty batch does not set failedMessages", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 100,
+ });
+ // No writes — batch is empty
+ const closePromise = new Promise((resolve) => stream.on("close", resolve));
+ stream.destroy();
+ await closePromise;
+ // No error should have been emitted with failedMessages
+ // since batch was empty we never set failedMessages
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: validation
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: throws when consumer is missing", async () => {
+ await rejects(
+ async () => kafkaConsumeStream({ topics: "t" }),
+ /kafkaConsumeStream: consumer required/,
+ );
+});
+
+test("kafkaConsumeStream: throws TypeError when consumer is missing", async () => {
+ await rejects(async () => kafkaConsumeStream({ topics: "t" }), TypeError);
+});
+
+test("kafkaConsumeStream: throws when topics is missing", async () => {
+ const { consumerMock } = makeFakeKafka();
+ await rejects(
+ async () => kafkaConsumeStream({ consumer: consumerMock }),
+ /kafkaConsumeStream: topics required/,
+ );
+});
+
+test("kafkaConsumeStream: throws TypeError when topics is missing", async () => {
+ const { consumerMock } = makeFakeKafka();
+ await rejects(
+ async () => kafkaConsumeStream({ consumer: consumerMock }),
+ TypeError,
+ );
+});
+
+test("kafkaConsumeStream: throws when topics is an empty array", async () => {
+ const { consumerMock } = makeFakeKafka();
+ await rejects(
+ async () => kafkaConsumeStream({ consumer: consumerMock, topics: [] }),
+ /kafkaConsumeStream: topics required/,
+ );
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: subscribe
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: subscribes to a single string topic", async () => {
+ const { consumerMock } = makeFakeKafka();
+ const stream = await kafkaConsumeStream({
+ consumer: consumerMock,
+ topics: "my-topic",
+ });
+ // Subscribe happens during stream creation; assert then clean up
+ deepStrictEqual(consumerMock.subscribeCalls, [
+ { topic: "my-topic", fromBeginning: false },
+ ]);
+ const closePromise = new Promise((r) => stream.once("close", r));
+ stream.destroy();
+ await closePromise;
+});
+
+test("kafkaConsumeStream: subscribes to multiple topics in array", async () => {
+ const { consumerMock } = makeFakeKafka();
+ const stream = await kafkaConsumeStream({
+ consumer: consumerMock,
+ topics: ["topic-a", "topic-b"],
+ });
+ deepStrictEqual(consumerMock.subscribeCalls, [
+ { topic: "topic-a", fromBeginning: false },
+ { topic: "topic-b", fromBeginning: false },
+ ]);
+ const closePromise = new Promise((r) => stream.once("close", r));
+ stream.destroy();
+ await closePromise;
+});
+
+test("kafkaConsumeStream: passes fromBeginning=true to subscribe", async () => {
+ const { consumerMock } = makeFakeKafka();
+ const stream = await kafkaConsumeStream({
+ consumer: consumerMock,
+ topics: "t",
+ fromBeginning: true,
+ });
+ strictEqual(consumerMock.subscribeCalls[0].fromBeginning, true);
+ const closePromise = new Promise((r) => stream.once("close", r));
+ stream.destroy();
+ await closePromise;
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: message delivery
+// ---------------------------------------------------------------------------
+
+/** Helper: build a fake consumer that delivers messages via run(). */
+const makeMessageConsumer = (_messages) => {
+ const consumerMock = {
+ subscribeCalls: [],
+ stopCalled: 0,
+ _eachMessage: null,
+ _runResolve: null,
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async (opts) => {
+ consumerMock.subscribeCalls.push(opts);
+ },
+ stop: async () => {
+ consumerMock.stopCalled++;
+ if (consumerMock._runResolve) consumerMock._runResolve();
+ },
+ run: async ({ eachMessage }) => {
+ consumerMock._eachMessage = eachMessage;
+ return new Promise((resolve) => {
+ consumerMock._runResolve = resolve;
+ });
+ },
+ };
+ return consumerMock;
+};
+
+test("kafkaConsumeStream: delivers messages from eachMessage to stream", async () => {
+ const msgs = [
+ {
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "0",
+ key: "k",
+ value: "v",
+ headers: {},
+ timestamp: "0",
+ },
+ },
+ {
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "1",
+ key: "k2",
+ value: "v2",
+ headers: {},
+ timestamp: "1",
+ },
+ },
+ ];
+ const consumer = makeMessageConsumer(msgs);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ // Deliver messages then stop
+ setTimeout(async () => {
+ for (const m of msgs) await consumer._eachMessage(m);
+ await stream.stop();
+ }, 0);
+
+ const received = await streamToArray(stream);
+ strictEqual(received.length, 2);
+ strictEqual(received[0].value, "v");
+ strictEqual(received[1].value, "v2");
+});
+
+test("kafkaConsumeStream: message object includes topic, partition, offset, key, value, headers, timestamp", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ setTimeout(async () => {
+ await consumer._eachMessage({
+ topic: "my-topic",
+ partition: 3,
+ message: {
+ offset: "42",
+ key: "mykey",
+ value: "myval",
+ headers: { x: "1" },
+ timestamp: "9999",
+ },
+ });
+ await stream.stop();
+ }, 0);
+
+ const [msg] = await streamToArray(stream);
+ strictEqual(msg.topic, "my-topic");
+ strictEqual(msg.partition, 3);
+ strictEqual(msg.offset, "42");
+ strictEqual(msg.key, "mykey");
+ strictEqual(msg.value, "myval");
+ deepStrictEqual(msg.headers, { x: "1" });
+ strictEqual(msg.timestamp, "9999");
+});
+
+test("kafkaConsumeStream: stop() ends the readable stream", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ const endedPromise = new Promise((resolve) => stream.once("end", resolve));
+ stream.resume();
+ setTimeout(() => stream.stop(), 0);
+ await endedPromise;
+});
+
+test("kafkaConsumeStream: stop() is idempotent (calling twice doesn't call consumer.stop twice)", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ await stream.stop();
+ await stream.stop(); // second call should be no-op
+ strictEqual(consumer.stopCalled, 1, "consumer.stop called only once");
+});
+
+test("kafkaConsumeStream: eachMessage after stopped is a no-op (does not push)", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ const receivedChunks = [];
+ stream.on("data", (chunk) => receivedChunks.push(chunk));
+
+ await stream.stop();
+ // After stop, eachMessage should return without pushing
+ await consumer._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "0",
+ key: null,
+ value: "dropped",
+ headers: {},
+ timestamp: "0",
+ },
+ });
+
+ // Give event loop a tick to see if anything was pushed
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ strictEqual(receivedChunks.length, 0, "no chunks after stop");
+});
+
+test("kafkaConsumeStream: passes autoCommit to consumer.run", async () => {
+ let capturedOpts;
+ const consumer = {
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumer._runResolve) consumer._runResolve();
+ },
+ run: async (opts) => {
+ capturedOpts = opts;
+ return new Promise((resolve) => {
+ consumer._runResolve = resolve;
+ });
+ },
+ };
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ autoCommit: false,
+ });
+ await stream.stop();
+ strictEqual(capturedOpts.autoCommit, false);
+ stream.destroy();
+});
+
+test("kafkaConsumeStream: passes partitionsConsumedConcurrently to consumer.run", async () => {
+ let capturedOpts;
+ const consumer = {
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumer._runResolve) consumer._runResolve();
+ },
+ run: async (opts) => {
+ capturedOpts = opts;
+ return new Promise((resolve) => {
+ consumer._runResolve = resolve;
+ });
+ },
+ };
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ partitionsConsumedConcurrently: 4,
+ });
+ await stream.stop();
+ strictEqual(capturedOpts.partitionsConsumedConcurrently, 4);
+ stream.destroy();
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: abort signal
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: already-aborted signal calls stop immediately", async () => {
+ const consumer = makeMessageConsumer([]);
+ const ac = new AbortController();
+ ac.abort();
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: ac.signal,
+ });
+ // Stream should be ended because signal was already aborted
+ const endedPromise = new Promise((resolve) => {
+ if (stream.readableEnded) return resolve();
+ stream.once("end", resolve);
+ });
+ stream.resume(); // drain to trigger end event
+ await endedPromise;
+});
+
+test("kafkaConsumeStream: abort signal triggers stop", async () => {
+ const consumer = makeMessageConsumer([]);
+ const ac = new AbortController();
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: ac.signal,
+ });
+ const endedPromise = new Promise((resolve) => stream.once("end", resolve));
+ stream.resume(); // start flowing
+ ac.abort();
+ await endedPromise;
+ strictEqual(consumer.stopCalled, 1);
+});
+
+test("kafkaConsumeStream: destroy() calls consumer.stop", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ const closePromise = new Promise((resolve) => stream.once("close", resolve));
+ stream.destroy();
+ await closePromise;
+ strictEqual(consumer.stopCalled, 1);
+});
+
+test("kafkaConsumeStream: destroy() removes abort listener (signal guard)", async () => {
+ const consumer = makeMessageConsumer([]);
+ const ac = new AbortController();
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: ac.signal,
+ });
+ const closePromise = new Promise((resolve) => stream.once("close", resolve));
+ stream.destroy();
+ await closePromise;
+
+ // Aborting after destroy should not call consumer.stop a second time
+ ac.abort();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ strictEqual(consumer.stopCalled, 1, "consumer.stop called only once");
+});
+
+test("kafkaConsumeStream: stop() removes abort listener so abort after stop is a no-op", async () => {
+ const consumer = makeMessageConsumer([]);
+ const ac = new AbortController();
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: ac.signal,
+ });
+ await stream.stop();
+
+ // Aborting after stop should not call consumer.stop again
+ ac.abort();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ strictEqual(consumer.stopCalled, 1);
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: highWaterMark option
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: respects highWaterMark streamOption", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream(
+ { consumer, topics: "t" },
+ { highWaterMark: 5 },
+ );
+ strictEqual(stream.readableHighWaterMark, 5);
+ await stream.stop();
+ stream.destroy();
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: backpressure (wantsMore === false triggers waitForRead)
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: backpressure suspends eachMessage until _read is called", async () => {
+ const consumer = makeMessageConsumer([]);
+ // Use a very small HWM so backpressure kicks in quickly
+ const stream = await kafkaConsumeStream(
+ { consumer, topics: "t" },
+ { highWaterMark: 1 },
+ );
+
+ let msg2Sent = false;
+ const deliveryDone = (async () => {
+ // Send first message - should go through
+ await consumer._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "0",
+ key: null,
+ value: "v1",
+ headers: {},
+ timestamp: "0",
+ },
+ });
+ // Send second message - may block until stream reads first
+ await consumer._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "1",
+ key: null,
+ value: "v2",
+ headers: {},
+ timestamp: "1",
+ },
+ });
+ msg2Sent = true;
+ await stream.stop();
+ })();
+
+ const received = await streamToArray(stream);
+ await deliveryDone;
+ ok(msg2Sent, "second message was eventually delivered");
+ strictEqual(received.length, 2);
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: stopConsumerOnce memoization
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: stopConsumerOnce is memoized (stop + destroy only calls consumer.stop once)", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ const closePromise = new Promise((resolve) => stream.once("close", resolve));
+ // Call stop() then destroy() — both trigger stopConsumerOnce
+ await stream.stop();
+ stream.destroy();
+ await closePromise;
+
+ strictEqual(consumer.stopCalled, 1, "consumer.stop called exactly once");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConnect: producerOptions passed to kafka.producer()
+// ---------------------------------------------------------------------------
+
+test("kafkaConnect: producerOptions are passed through to kafka.producer()", async () => {
+ let capturedProducerOpts;
+ const FakeKafka = function () {
+ this.producer = (opts) => {
+ capturedProducerOpts = opts;
+ return { connect: async () => {}, disconnect: async () => {} };
+ };
+ this.consumer = () => ({
+ connect: async () => {},
+ disconnect: async () => {},
+ });
+ };
+ const producerOptions = { transactionalId: "tx-1", idempotent: true };
+ await kafkaConnect({ producer: producerOptions, Kafka: FakeKafka });
+ deepStrictEqual(capturedProducerOpts, producerOptions);
+});
+
+test("kafkaConnect: producerOptions ?? {} default passes empty object to kafka.producer()", async () => {
+ let capturedProducerOpts;
+ const FakeKafka = function () {
+ this.producer = (opts) => {
+ capturedProducerOpts = opts;
+ return { connect: async () => {}, disconnect: async () => {} };
+ };
+ this.consumer = () => ({
+ connect: async () => {},
+ disconnect: async () => {},
+ });
+ };
+ // No producerOptions provided — should default to {}
+ await kafkaConnect({ Kafka: FakeKafka });
+ deepStrictEqual(capturedProducerOpts, {});
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConnect: optional chaining in error teardown
+// ---------------------------------------------------------------------------
+
+test("kafkaConnect: consumer connect error with producer===false does not throw in teardown", async () => {
+ // producer is null; in the catch block, producer?.disconnect() must NOT throw
+ const FakeKafka = function () {
+ this.producer = () => ({
+ connect: async () => {},
+ disconnect: async () => {},
+ });
+ this.consumer = () => ({
+ connect: async () => {
+ throw new Error("consumer connect failed");
+ },
+ disconnect: async () => {},
+ });
+ };
+ // producer: false => producer is null => in catch block producer?.disconnect() is safe
+ await rejects(
+ async () =>
+ kafkaConnect({ producer: false, groupId: "g1", Kafka: FakeKafka }),
+ /consumer connect failed/,
+ "should rethrow consumer connect error",
+ );
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: null chunk error message specifics
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: error message includes type 'number' for numeric chunk", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const errPromise = new Promise((resolve) => stream.once("error", resolve));
+ stream.write(123);
+ const err = await errPromise;
+ ok(
+ err.message.includes("number"),
+ "error message includes 'number' (not empty string)",
+ );
+ ok(err.message.length > 10, "error message is not empty");
+});
+
+test("kafkaProduceStream: normalizeMessage: object without value property throws TypeError with type 'object'", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ });
+ const errPromise = new Promise((resolve) => stream.once("error", resolve));
+ stream.write({ noValue: "here" });
+ const err = await errPromise;
+ ok(err instanceof TypeError, "should be TypeError");
+ ok(err.message.includes("object"), "message includes type 'object'");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaProduceStream: batch reset is correct after flush
+// ---------------------------------------------------------------------------
+
+test("kafkaProduceStream: after batch flush, next messages go into a fresh batch", async () => {
+ const { producerMock } = makeFakeKafka();
+ const stream = await kafkaProduceStream({
+ producer: producerMock,
+ topic: "t",
+ batchSize: 2,
+ });
+ // 4 messages = 2 full batches
+ stream.write("a");
+ stream.write("b");
+ stream.write("c");
+ stream.write("d");
+ stream.end();
+ await new Promise((resolve) => stream.on("finish", resolve));
+ strictEqual(producerMock.sendArgs.length, 2, "two separate flushes");
+ // First batch: a, b
+ strictEqual(producerMock.sendArgs[0].messages[0].value, "a");
+ strictEqual(producerMock.sendArgs[0].messages[1].value, "b");
+ // Second batch: c, d (not ["Stryker was here", "c", "d"])
+ strictEqual(producerMock.sendArgs[1].messages.length, 2);
+ strictEqual(producerMock.sendArgs[1].messages[0].value, "c");
+ strictEqual(producerMock.sendArgs[1].messages[1].value, "d");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: autoCommit default true is passed to consumer.run
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: autoCommit defaults to true and is passed to consumer.run", async () => {
+ let capturedOpts;
+ const consumer = {
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stop: async () => {
+ if (consumer._runResolve) consumer._runResolve();
+ },
+ run: async (opts) => {
+ capturedOpts = opts;
+ return new Promise((resolve) => {
+ consumer._runResolve = resolve;
+ });
+ },
+ };
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+ // Default autoCommit should be true
+ strictEqual(capturedOpts.autoCommit, true);
+ const closePromise = new Promise((r) => stream.once("close", r));
+ stream.destroy();
+ await closePromise;
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: destroy sets stopped=true so eachMessage is a no-op
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: destroy() sets stopped=true so eachMessage after destroy is a no-op", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+
+ const closePromise = new Promise((resolve) => stream.once("close", resolve));
+ stream.destroy();
+ await closePromise;
+
+ // Try delivering a message after destroy — should be a no-op (stopped===true)
+ if (consumer._eachMessage) {
+ const _chunksPushed = [];
+ // The stream is already destroyed so push won't add data; just verify no throw
+ await consumer._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "99",
+ key: null,
+ value: "late",
+ headers: {},
+ timestamp: "0",
+ },
+ });
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: abort event listener uses "abort" event name
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: abort event listener is on 'abort' event (not '')", async () => {
+ const consumer = makeMessageConsumer([]);
+ const listeners = [];
+ const mockSignal = {
+ aborted: false,
+ addEventListener: (event, fn) => {
+ listeners.push({ event, fn });
+ },
+ removeEventListener: (event, fn) => {
+ const idx = listeners.findIndex((l) => l.event === event && l.fn === fn);
+ if (idx !== -1) listeners.splice(idx, 1);
+ },
+ };
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: mockSignal,
+ });
+ // Listener was registered on 'abort' event
+ strictEqual(listeners.length, 1, "one listener added");
+ strictEqual(listeners[0].event, "abort", "listener is on 'abort' event");
+
+ // stop() removes it
+ await stream.stop();
+ strictEqual(listeners.length, 0, "listener removed after stop");
+ stream.destroy();
+});
+
+test("kafkaConsumeStream: destroy() removes 'abort' listener (correct event name)", async () => {
+ const consumer = makeMessageConsumer([]);
+ const listeners = [];
+ const mockSignal = {
+ aborted: false,
+ addEventListener: (event, fn) => {
+ listeners.push({ event, fn });
+ },
+ removeEventListener: (event, fn) => {
+ const idx = listeners.findIndex((l) => l.event === event && l.fn === fn);
+ if (idx !== -1) listeners.splice(idx, 1);
+ },
+ };
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: mockSignal,
+ });
+ strictEqual(listeners.length, 1);
+
+ const closePromise = new Promise((resolve) => stream.once("close", resolve));
+ stream.destroy();
+ await closePromise;
+
+ strictEqual(listeners.length, 0, "listener removed on destroy");
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: stop() correctly handles already-stopped idempotency
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: stop() when not stopped pushes null to end stream", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+ stream.resume();
+
+ const endPromise = new Promise((resolve) => stream.once("end", resolve));
+ await stream.stop();
+ await endPromise;
+ // Second call is no-op
+ await stream.stop();
+ strictEqual(consumer.stopCalled, 1);
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: wantsMore backpressure - eachMessage completes when wantsMore=true
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: wantsMore=true means eachMessage does NOT call waitForRead", async () => {
+ // With large HWM, push() returns true (wantsMore), so waitForRead is NOT called
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream(
+ { consumer, topics: "t" },
+ { highWaterMark: 100 },
+ );
+
+ let delivered = false;
+ const delivery = (async () => {
+ await consumer._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "0",
+ key: null,
+ value: "v",
+ headers: {},
+ timestamp: "0",
+ },
+ });
+ delivered = true;
+ })();
+
+ // Give a tick; eachMessage should complete without blocking (no waitForRead)
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ ok(delivered, "eachMessage completed without waiting for read");
+
+ await stream.stop();
+ await delivery;
+ stream.destroy();
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: signal.aborted check prevents double-stop
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: non-aborted signal does NOT call stop immediately", async () => {
+ const consumer = makeMessageConsumer([]);
+ const ac = new AbortController();
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: ac.signal,
+ });
+ // Consumer.stop should NOT have been called yet (signal is not aborted)
+ strictEqual(consumer.stopCalled, 0, "stop not called for non-aborted signal");
+
+ await stream.stop();
+ stream.destroy();
+});
+
+test("kafkaConsumeStream: non-aborted signal registers abort listener (not stop immediately)", async () => {
+ const consumer = makeMessageConsumer([]);
+ const listeners = [];
+ const mockSignal = {
+ aborted: false,
+ addEventListener: (event, fn) => listeners.push({ event, fn }),
+ removeEventListener: (_event, fn) => {
+ const idx = listeners.findIndex((l) => l.fn === fn);
+ if (idx !== -1) listeners.splice(idx, 1);
+ },
+ };
+
+ const stream = await kafkaConsumeStream({
+ consumer,
+ topics: "t",
+ signal: mockSignal,
+ });
+ // Should have added listener (not called stop)
+ strictEqual(consumer.stopCalled, 0);
+ strictEqual(listeners.length, 1);
+ strictEqual(listeners[0].event, "abort");
+ await stream.stop();
+ stream.destroy();
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConnect: dynamic import fallback (Kafka not injected)
+// ---------------------------------------------------------------------------
+
+test("kafkaConnect: uses dynamic import when Kafka is not provided (no connect)", async () => {
+ // Call without injecting Kafka — exercises the `Kafka ??= (await import('kafkajs')).Kafka` branch.
+ // Pass producer:false and omit groupId so neither producer nor consumer is
+ // created, meaning connect() is never called and no broker is needed.
+ const result = await kafkaConnect({ producer: false });
+ strictEqual(result.producer, null, "producer is null");
+ strictEqual(result.consumer, null, "consumer is null");
+ ok(result.kafka, "kafka instance created via dynamic import");
+ ok(typeof result.disconnect === "function");
+ await result.disconnect();
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConnect: disconnect() throws during error teardown (catch handlers)
+// ---------------------------------------------------------------------------
+
+test("kafkaConnect: producer.disconnect() throwing in catch block is swallowed", async () => {
+ const { Kafka } = makeFakeKafka({
+ connectProducer: async () => {
+ throw new Error("producer connect failed");
+ },
+ disconnectProducer: async () => {
+ throw new Error("producer disconnect also failed");
+ },
+ });
+ // Should still rethrow the original connect error, not the disconnect error
+ await rejects(async () => kafkaConnect({ Kafka }), /producer connect failed/);
+});
+
+test("kafkaConnect: consumer.disconnect() throwing in catch block is swallowed", async () => {
+ const { Kafka } = makeFakeKafka({
+ connectConsumer: async () => {
+ throw new Error("consumer connect failed");
+ },
+ disconnectConsumer: async () => {
+ throw new Error("consumer disconnect also failed");
+ },
+ });
+ // Should rethrow the consumer connect error despite disconnect throwing
+ await rejects(
+ async () => kafkaConnect({ groupId: "g1", Kafka }),
+ /consumer connect failed/,
+ );
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: consumer.stop() throwing is swallowed by stopConsumerOnce
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: consumer.stop() throwing is swallowed (stopConsumerOnce catch)", async () => {
+ // Build a consumer whose stop() rejects — this exercises the .catch(() => {})
+ // in stopConsumerOnce, which must not propagate the error.
+ const consumer = {
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stopCalled: 0,
+ _runResolve: null,
+ stop: async () => {
+ consumer.stopCalled++;
+ if (consumer._runResolve) consumer._runResolve();
+ throw new Error("consumer.stop exploded");
+ },
+ run: async () =>
+ new Promise((resolve) => {
+ consumer._runResolve = resolve;
+ }),
+ };
+
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+ stream.resume();
+ // stop() must not throw even though consumer.stop() does
+ await stream.stop();
+ strictEqual(consumer.stopCalled, 1);
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: runPromise rejection is swallowed (anonymous_22)
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: precise backpressure contract
+// - when the buffer is FULL (push returns false / wantsMore=false) eachMessage
+// MUST suspend on waitForRead() until the next _read releases it.
+// - when the buffer has ROOM (push returns true / wantsMore=true) eachMessage
+// MUST NOT suspend.
+// These pin down `if (!wantsMore) await waitForRead()` and waitForRead()'s
+// Promise so the BooleanLiteral / ConditionalExpression / ArrowFunction mutants
+// all fail.
+// ---------------------------------------------------------------------------
+
+const microtick = () => new Promise((resolve) => setImmediate(resolve));
+
+test("kafkaConsumeStream: eachMessage SUSPENDS while buffer is full, resumes on read", async () => {
+ const consumer = makeMessageConsumer([]);
+ // HWM 1 => first push fills the buffer and returns false (wantsMore=false).
+ const stream = await kafkaConsumeStream(
+ { consumer, topics: "t" },
+ { highWaterMark: 1 },
+ );
+
+ let settled = false;
+ const p = consumer
+ ._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "0",
+ key: null,
+ value: "v1",
+ headers: {},
+ timestamp: "0",
+ },
+ })
+ .then(() => {
+ settled = true;
+ });
+
+ // eachMessage must still be parked on waitForRead() — nothing has read yet.
+ await microtick();
+ strictEqual(
+ settled,
+ false,
+ "eachMessage should be suspended while buffer full",
+ );
+
+ // Reading one item triggers _read -> releaseReaders -> resolves waitForRead.
+ const item = stream.read();
+ strictEqual(item.value, "v1");
+ await p;
+ strictEqual(settled, true, "eachMessage resumes after _read");
+
+ await stream.stop();
+ stream.destroy();
+});
+
+test("kafkaConsumeStream: eachMessage does NOT suspend while buffer has room", async () => {
+ const consumer = makeMessageConsumer([]);
+ // Large HWM => push returns true (wantsMore=true) => no waitForRead().
+ const stream = await kafkaConsumeStream(
+ { consumer, topics: "t" },
+ { highWaterMark: 100 },
+ );
+
+ let settled = false;
+ const p = consumer
+ ._eachMessage({
+ topic: "t",
+ partition: 0,
+ message: {
+ offset: "0",
+ key: null,
+ value: "v1",
+ headers: {},
+ timestamp: "0",
+ },
+ })
+ .then(() => {
+ settled = true;
+ });
+
+ // Drain MICROTASKS only (no setImmediate / I/O turn). The stream's internal
+ // _read() — which would release a wrongly-parked waiter — is scheduled on a
+ // macrotask, so it has NOT run yet. With correct code (wantsMore=true => no
+ // waitForRead) eachMessage resolves purely via microtasks. With a mutant that
+ // always waits (`if (true)`) or inverts the guard, eachMessage is still parked
+ // here because only a macrotask _read could release it.
+ for (let i = 0; i < 20; i++) await Promise.resolve();
+ strictEqual(
+ settled,
+ true,
+ "eachMessage should complete via microtasks (no waitForRead) when buffer has room",
+ );
+ await p;
+
+ await stream.stop();
+ stream.destroy();
+});
+
+// ---------------------------------------------------------------------------
+// kafkaConsumeStream: stop() idempotency guard (if (stopped) return) prevents a
+// second stream.push(null), which would emit ERR_STREAM_PUSH_AFTER_EOF.
+// ---------------------------------------------------------------------------
+
+test("kafkaConsumeStream: second stop() does not push null again / emit an error", async () => {
+ const consumer = makeMessageConsumer([]);
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+ stream.resume();
+
+ let errored = null;
+ stream.on("error", (e) => {
+ errored = e;
+ });
+
+ const endPromise = new Promise((resolve) => stream.once("end", resolve));
+ await stream.stop();
+ await endPromise;
+ // Second stop() must early-return; without the `if (stopped) return` guard it
+ // would call stream.push(null) again after EOF and emit an error event.
+ await stream.stop();
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ strictEqual(errored, null, "no error from a redundant second stop()");
+ strictEqual(consumer.stopCalled, 1);
+});
+
+test("kafkaConsumeStream: runPromise rejection is swallowed by stop()", async () => {
+ // Build a consumer whose run() promise rejects — exercises the
+ // runPromise.catch(() => {}) in stop(), which must swallow the error.
+ const consumer = {
+ connect: async () => {},
+ disconnect: async () => {},
+ subscribe: async () => {},
+ stopCalled: 0,
+ _runResolve: null,
+ _runReject: null,
+ stop: async () => {
+ consumer.stopCalled++;
+ if (consumer._runReject) consumer._runReject(new Error("run rejected"));
+ },
+ run: async () =>
+ new Promise((_resolve, reject) => {
+ consumer._runReject = reject;
+ }),
+ };
+
+ const stream = await kafkaConsumeStream({ consumer, topics: "t" });
+ stream.resume();
+ // stop() must not throw even though runPromise rejects
+ await stream.stop();
+ strictEqual(consumer.stopCalled, 1);
+});
diff --git a/packages/kafka/index.tst.ts b/packages/kafka/index.tst.ts
new file mode 100644
index 0000000..c19800c
--- /dev/null
+++ b/packages/kafka/index.tst.ts
@@ -0,0 +1,71 @@
+///
+///
+import type { KafkaConnection } from "@datastream/kafka";
+import {
+ kafkaConnect,
+ kafkaConsumeStream,
+ kafkaProduceStream,
+} from "@datastream/kafka";
+import { describe, expect, test } from "tstyche";
+
+const producer: unknown = {};
+const consumer: unknown = {};
+
+describe("kafkaConnect", () => {
+ test("accepts no args", () => {
+ expect(kafkaConnect()).type.not.toBeAssignableTo();
+ });
+
+ test("accepts broker config", () => {
+ expect(
+ kafkaConnect({
+ brokers: ["localhost:9092"],
+ clientId: "c",
+ groupId: "g",
+ }),
+ ).type.not.toBeAssignableTo();
+ });
+
+ test("accepts producer: false", () => {
+ expect(
+ kafkaConnect({ producer: false }),
+ ).type.not.toBeAssignableTo();
+ });
+
+ test("resolves a KafkaConnection", () => {
+ expect(kafkaConnect()).type.toBe>();
+ });
+});
+
+describe("kafkaProduceStream", () => {
+ test("requires producer and topic", () => {
+ expect(
+ kafkaProduceStream({ producer, topic: "t" }),
+ ).type.not.toBeAssignableTo();
+ });
+
+ test("accepts batching options", () => {
+ expect(
+ kafkaProduceStream({ producer, topic: "t", batchSize: 50, acks: -1 }),
+ ).type.not.toBeAssignableTo();
+ });
+});
+
+describe("kafkaConsumeStream", () => {
+ test("requires consumer and topics", () => {
+ expect(
+ kafkaConsumeStream({ consumer, topics: "t" }),
+ ).type.not.toBeAssignableTo();
+ });
+
+ test("accepts an array of topics and a signal", () => {
+ expect(
+ kafkaConsumeStream({
+ consumer,
+ topics: ["a", "b"],
+ fromBeginning: true,
+ signal: new AbortController().signal,
+ }),
+ ).type.not.toBeAssignableTo();
+ });
+});
diff --git a/packages/kafka/index.web.js b/packages/kafka/index.web.js
new file mode 100644
index 0000000..2251365
--- /dev/null
+++ b/packages/kafka/index.web.js
@@ -0,0 +1,24 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+
+// @datastream/kafka wraps kafkajs, which speaks the Kafka wire protocol over raw
+// TCP sockets — unavailable in the browser. There is no Web Streams equivalent,
+// so the browser/`webstream` export resolves to these stubs: importing the
+// package stays side-effect free (safe for bundlers and SSR), but calling any
+// export fails loudly instead of silently returning from an empty module. Use
+// this package from Node.js.
+const unsupported = (name) => () => {
+ throw new Error(
+ `${name} is not supported in the browser: @datastream/kafka requires Node.js (kafkajs over TCP).`,
+ );
+};
+
+export const kafkaConnect = unsupported("kafkaConnect");
+export const kafkaProduceStream = unsupported("kafkaProduceStream");
+export const kafkaConsumeStream = unsupported("kafkaConsumeStream");
+
+export default {
+ connect: kafkaConnect,
+ produceStream: kafkaProduceStream,
+ consumeStream: kafkaConsumeStream,
+};
diff --git a/packages/kafka/package.json b/packages/kafka/package.json
new file mode 100644
index 0000000..88542d9
--- /dev/null
+++ b/packages/kafka/package.json
@@ -0,0 +1,81 @@
+{
+ "name": "@datastream/kafka",
+ "version": "0.6.1",
+ "description": "Kafka producer and consumer streams (Node, via kafkajs)",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "engineStrict": true,
+ "publishConfig": {
+ "access": "public"
+ },
+ "main": "./index.node.mjs",
+ "module": "./index.web.mjs",
+ "exports": {
+ ".": {
+ "node": {
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.node.mjs"
+ }
+ },
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.web.mjs"
+ }
+ },
+ "./webstream": {
+ "types": "./index.d.ts",
+ "default": "./index.web.mjs"
+ }
+ },
+ "types": "index.d.ts",
+ "files": [
+ "*.mjs",
+ "*.map",
+ "*.d.ts"
+ ],
+ "scripts": {
+ "test": "npm run test:unit",
+ "test:unit": "node --test",
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
+ },
+ "license": "MIT",
+ "keywords": [
+ "kafka",
+ "kafkajs",
+ "msk",
+ "streaming",
+ "Web Stream API",
+ "Node Stream API"
+ ],
+ "author": {
+ "name": "datastream contributors",
+ "url": "https://github.com/willfarrell/datastream/graphs/contributors"
+ },
+ "repository": {
+ "type": "git",
+ "url": "github:willfarrell/datastream",
+ "directory": "packages/kafka"
+ },
+ "bugs": {
+ "url": "https://github.com/willfarrell/datastream/issues"
+ },
+ "homepage": "https://datastream.js.org",
+ "dependencies": {
+ "@datastream/core": "0.6.1"
+ },
+ "peerDependencies": {
+ "kafkajs": "^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "kafkajs": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "kafkajs": "^2.0.0"
+ }
+}
diff --git a/packages/object/README.md b/packages/object/README.md
index 915d58f..0b41113 100644
--- a/packages/object/README.md
+++ b/packages/object/README.md
@@ -1,6 +1,6 @@
<datastream> `object`
-
+
Object manipulation streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/object/index.js b/packages/object/index.js
index 61cec69..17b43fc 100644
--- a/packages/object/index.js
+++ b/packages/object/index.js
@@ -31,7 +31,7 @@ export const objectBatchStream = (
let previousId;
let batch;
const transform = (chunk, enqueue) => {
- const id = keys.map((key) => chunk[key]).join(" ");
+ const id = JSON.stringify(keys.map((key) => chunk[key]));
if (previousId !== id) {
if (batch) {
enqueue(batch);
@@ -163,7 +163,9 @@ export const objectKeyMapStream = ({ keys }, streamOptions = {}) => {
export const objectValueMapStream = ({ key, values }, streamOptions = {}) => {
const transform = (chunk, enqueue) => {
- chunk[key] = values[chunk[key]];
+ if (Object.hasOwn(values, chunk[key])) {
+ chunk[key] = values[chunk[key]];
+ }
enqueue(chunk);
};
return createTransformStream(transform, streamOptions);
diff --git a/packages/object/index.test.js b/packages/object/index.test.js
index 6c0a741..15b2982 100644
--- a/packages/object/index.test.js
+++ b/packages/object/index.test.js
@@ -7,7 +7,7 @@ import {
streamToArray,
} from "@datastream/core";
-import {
+import objectDefault, {
objectBatchStream,
objectCountStream,
objectFromEntriesStream,
@@ -519,6 +519,46 @@ test(`${variant}: objectPivotWideToLongStream should not mutate input chunks`, a
deepStrictEqual(input[0], original);
});
+// *** objectPivotWideToLongStream isNestedObject deep clone *** //
+test(`${variant}: objectPivotWideToLongStream should deep clone when isNestedObject is true`, async (_t) => {
+ const input = [{ id: 1, a: { x: 10 }, b: { x: 20 } }];
+ const streams = [
+ createReadableStream(input),
+ objectPivotWideToLongStream({
+ keys: ["a", "b"],
+ keyParam: "axis",
+ valueParam: "val",
+ isNestedObject: true,
+ }),
+ ];
+
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+
+ deepStrictEqual(output, [
+ { id: 1, axis: "a", val: { x: 10 } },
+ { id: 1, axis: "b", val: { x: 20 } },
+ ]);
+});
+
+// *** objectKeyJoinStream isNestedObject deep clone *** //
+test(`${variant}: objectKeyJoinStream should deep clone when isNestedObject is true`, async (_t) => {
+ const input = [{ firstName: "John", lastName: "Doe", nested: { v: 1 } }];
+ const streams = [
+ createReadableStream(input),
+ objectKeyJoinStream({
+ keys: { fullName: ["firstName", "lastName"] },
+ separator: " ",
+ isNestedObject: true,
+ }),
+ ];
+
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+
+ deepStrictEqual(output, [{ nested: { v: 1 }, fullName: "John Doe" }]);
+});
+
// *** objectKeyJoinStream shallow copy regression *** //
test(`${variant}: objectKeyJoinStream should not mutate input chunks`, async (_t) => {
const input = [{ first: "a", last: "b", other: "c" }];
@@ -557,6 +597,40 @@ test(`${variant}: objectBatchStream should enforce maxBatchSize`, async (_t) =>
);
});
+// *** objectBatchStream collision-proof grouping key *** //
+test(`${variant}: objectBatchStream should keep distinct key tuples in separate batches when values contain spaces`, async (_t) => {
+ // ["a b","c"] and ["a","b c"] both join to "a b c" with space-delimiter —
+ // they must produce two separate batches, not one merged batch.
+ const input = [
+ { x: "a b", y: "c" },
+ { x: "a", y: "b c" },
+ ];
+ const streams = [
+ createReadableStream(input),
+ objectBatchStream({ keys: ["x", "y"] }),
+ ];
+
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+
+ deepStrictEqual(output, [[{ x: "a b", y: "c" }], [{ x: "a", y: "b c" }]]);
+});
+
+// *** objectValueMapStream preserves unmapped keys *** //
+test(`${variant}: objectValueMapStream should preserve the original value for keys not present in the map`, async (_t) => {
+ const input = [{ status: "active" }, { status: "unknown" }];
+ const streams = [
+ createReadableStream(input),
+ objectValueMapStream({ key: "status", values: { active: 1, inactive: 0 } }),
+ ];
+
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+
+ // "unknown" is not in the map — original value must be preserved
+ deepStrictEqual(output, [{ status: 1 }, { status: "unknown" }]);
+});
+
// *** objectPivotLongToWideStream should not mutate input *** //
test(`${variant}: objectPivotLongToWideStream should not mutate input chunks`, async (_t) => {
const input = [
@@ -579,3 +653,52 @@ test(`${variant}: objectPivotLongToWideStream should not mutate input chunks`, a
// Original input[0][0] should be unchanged
deepStrictEqual(input[0][0], originalFirst);
});
+
+// *** objectReadableStream default empty input *** //
+test(`${variant}: objectReadableStream should produce empty output when called with no args`, async (_t) => {
+ const streams = [objectReadableStream()];
+ const output = await streamToArray(streams[0]);
+ deepStrictEqual(output, []);
+});
+
+// *** objectBatchStream should produce no output on empty input *** //
+test(`${variant}: objectBatchStream should produce no output when input is empty`, async (_t) => {
+ const streams = [
+ createReadableStream([]),
+ objectBatchStream({ keys: ["a"] }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, []);
+});
+
+// *** objectToEntriesStream should produce empty arrays when keys is empty *** //
+test(`${variant}: objectToEntriesStream should produce empty array when keys is empty`, async (_t) => {
+ const input = [{ a: 1, b: 2 }];
+ const streams = [
+ createReadableStream(input),
+ objectToEntriesStream({ keys: [] }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, [[]]);
+});
+
+// *** default export contains all stream factories *** //
+test(`${variant}: default export should contain all stream factories`, async (_t) => {
+ strictEqual(typeof objectDefault.readableStream, "function");
+ strictEqual(typeof objectDefault.countStream, "function");
+ strictEqual(typeof objectDefault.pickStream, "function");
+ strictEqual(typeof objectDefault.omitStream, "function");
+ strictEqual(typeof objectDefault.batchStream, "function");
+ strictEqual(typeof objectDefault.pivotLongToWideStream, "function");
+ strictEqual(typeof objectDefault.pivotWideToLongStream, "function");
+ strictEqual(typeof objectDefault.keyValueStream, "function");
+ strictEqual(typeof objectDefault.keyValuesStream, "function");
+ strictEqual(typeof objectDefault.keyJoinStream, "function");
+ strictEqual(typeof objectDefault.keyMapStream, "function");
+ strictEqual(typeof objectDefault.valueMapStream, "function");
+ strictEqual(typeof objectDefault.fromEntriesStream, "function");
+ strictEqual(typeof objectDefault.toEntriesStream, "function");
+ strictEqual(typeof objectDefault.skipConsecutiveDuplicatesStream, "function");
+});
diff --git a/packages/object/package.json b/packages/object/package.json
index c922759..46dbd3a 100644
--- a/packages/object/package.json
+++ b/packages/object/package.json
@@ -1,6 +1,6 @@
{
"name": "@datastream/object",
- "version": "0.5.0",
+ "version": "0.6.1",
"description": "Object transform streams for picking, omitting, pivoting, batching, and key mapping",
"type": "module",
"engines": {
@@ -39,7 +39,8 @@
"scripts": {
"test": "npm run test:unit",
"test:unit": "node --test",
- "test:benchmark": "node __benchmarks__/index.js"
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
},
"license": "MIT",
"keywords": [
@@ -60,6 +61,6 @@
},
"homepage": "https://datastream.js.org",
"dependencies": {
- "@datastream/core": "0.5.0"
+ "@datastream/core": "0.6.1"
}
}
diff --git a/packages/protobuf/README.md b/packages/protobuf/README.md
new file mode 100644
index 0000000..32a67dc
--- /dev/null
+++ b/packages/protobuf/README.md
@@ -0,0 +1,52 @@
+
+ <datastream> `protobuf`
+
+ Protobuf encode/decode and length-prefix framing streams.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You can read the documentation at: https://datastream.js.org
+
+
+
+## Install
+
+To install datastream you can use NPM:
+
+```bash
+npm install --save @datastream/protobuf
+```
+
+
+## Documentation and examples
+
+For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org).
+
+
+## Contributing
+
+Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls).
+
+
+## License
+
+Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors).
\ No newline at end of file
diff --git a/packages/protobuf/_old.js b/packages/protobuf/_old.js
new file mode 100644
index 0000000..e69de29
diff --git a/packages/protobuf/index.d.ts b/packages/protobuf/index.d.ts
new file mode 100644
index 0000000..057fac4
--- /dev/null
+++ b/packages/protobuf/index.d.ts
@@ -0,0 +1,50 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import type { DatastreamTransform, StreamOptions } from "@datastream/core";
+
+export interface ProtobufType {
+ encode(message: unknown): { finish(): Uint8Array };
+ decode(buffer: Uint8Array): unknown;
+ create(message: unknown): unknown;
+}
+
+// Type can be:
+// - a static ProtobufType value
+// - a sync function that takes the current chunk and returns a Type
+// - an async function returning a Promise
+// When a static value is passed, it's cached so the hot path doesn't recompute.
+export type ProtobufTypeInput =
+ | ProtobufType
+ | ((chunk: unknown) => ProtobufType | Promise);
+
+export function protobufEncodeStream(
+ options: { Type: ProtobufTypeInput },
+ streamOptions?: StreamOptions,
+): DatastreamTransform;
+
+export function protobufDecodeStream(
+ options: {
+ Type: ProtobufTypeInput;
+ /** Extract the protobuf payload bytes from each chunk. Default: identity. */
+ payload?: (chunk: C) => Uint8Array;
+ /**
+ * Caps the CUMULATIVE encoded INPUT bytes processed across the whole
+ * stream. This is a coarse input-volume guard, NOT a bound on decoded
+ * output memory: protobuf can expand a small encoded message into a much
+ * larger in-memory object graph (packed/repeated/nested fields), so the
+ * decoded objects may exceed this value.
+ */
+ maxOutputSize?: number;
+ },
+ streamOptions?: StreamOptions,
+): DatastreamTransform;
+
+export function protobufLengthPrefixFrameStream(
+ options?: Record,
+ streamOptions?: StreamOptions,
+): DatastreamTransform;
+
+export function protobufLengthPrefixUnframeStream(
+ options?: { maxMessageSize?: number },
+ streamOptions?: StreamOptions,
+): DatastreamTransform;
diff --git a/packages/protobuf/index.fuzz.js b/packages/protobuf/index.fuzz.js
new file mode 100644
index 0000000..e2b10b9
--- /dev/null
+++ b/packages/protobuf/index.fuzz.js
@@ -0,0 +1,234 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import test from "node:test";
+import {
+ createReadableStream,
+ pipejoin,
+ pipeline,
+ streamToArray,
+} from "@datastream/core";
+import {
+ protobufDecodeStream,
+ protobufEncodeStream,
+ protobufLengthPrefixFrameStream,
+ protobufLengthPrefixUnframeStream,
+} from "@datastream/protobuf";
+import fc from "fast-check";
+import protobuf from "protobufjs";
+
+const Type = new protobuf.Type("Msg")
+ .add(new protobuf.Field("id", 1, "int32"))
+ .add(new protobuf.Field("name", 2, "string"));
+
+const catchError = (input, e) => {
+ if (
+ e.message.includes("maxMessageSize") ||
+ e.message.includes("maxOutputSize") ||
+ e.message.includes("incomplete message") ||
+ /invalid wire type/i.test(e.message) ||
+ /invalid tag/i.test(e.message) ||
+ /invalid varint/i.test(e.message) ||
+ /invalid end group/i.test(e.message) ||
+ /index out of range/i.test(e.message) ||
+ /max depth exceeded/i.test(e.message) ||
+ /illegal/i.test(e.message)
+ ) {
+ return;
+ }
+ console.error(input, e);
+ throw e;
+};
+
+// *** protobufEncodeStream + protobufDecodeStream round-trip *** //
+test("fuzz encode→decode round-trip with random {id, name}", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(
+ fc.record({
+ id: fc.integer({ min: -2147483648, max: 2147483647 }),
+ name: fc.string(),
+ }),
+ { minLength: 1 },
+ ),
+ async (input) => {
+ try {
+ const encoded = await streamToArray(
+ pipejoin([
+ createReadableStream(input),
+ protobufEncodeStream({ Type }),
+ ]),
+ );
+ const decoded = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufDecodeStream({ Type }),
+ ]),
+ );
+ for (let i = 0; i < input.length; i++) {
+ const got = decoded[i];
+ const want = input[i];
+ if (got.id !== want.id || got.name !== want.name) {
+ throw new Error(
+ `Round-trip mismatch at ${i}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`,
+ );
+ }
+ }
+ } catch (e) {
+ catchError(input, e);
+ }
+ },
+ ),
+ { numRuns: 1_000, verbose: 2, examples: [] },
+ );
+});
+
+// *** protobufDecodeStream with random bytes *** //
+test("fuzz protobufDecodeStream with random byte inputs", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(fc.uint8Array({ minLength: 0, maxLength: 64 }), {
+ minLength: 1,
+ }),
+ async (chunks) => {
+ try {
+ await pipeline([
+ createReadableStream(chunks),
+ protobufDecodeStream({ Type }),
+ ]);
+ } catch (e) {
+ catchError(chunks, e);
+ }
+ },
+ ),
+ { numRuns: 1_000, verbose: 2, examples: [] },
+ );
+});
+
+// *** protobufDecodeStream maxOutputSize guard *** //
+test("fuzz protobufDecodeStream with maxOutputSize", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(
+ fc.record({
+ id: fc.integer({ min: 0, max: 100 }),
+ name: fc.string({ maxLength: 32 }),
+ }),
+ { minLength: 1, maxLength: 10 },
+ ),
+ fc.integer({ min: 1, max: 512 }),
+ async (input, maxOutputSize) => {
+ try {
+ const encoded = await streamToArray(
+ pipejoin([
+ createReadableStream(input),
+ protobufEncodeStream({ Type }),
+ ]),
+ );
+ await pipeline([
+ createReadableStream(encoded),
+ protobufDecodeStream({ Type, maxOutputSize }),
+ ]);
+ } catch (e) {
+ catchError(input, e);
+ }
+ },
+ ),
+ { numRuns: 1_000, verbose: 2, examples: [] },
+ );
+});
+
+// *** protobufLengthPrefixUnframeStream with random byte chunks *** //
+test("fuzz protobufLengthPrefixUnframeStream with random byte chunks", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(fc.uint8Array({ minLength: 0, maxLength: 64 }), {
+ minLength: 1,
+ }),
+ async (chunks) => {
+ try {
+ await pipeline([
+ createReadableStream(chunks),
+ protobufLengthPrefixUnframeStream(),
+ ]);
+ } catch (e) {
+ catchError(chunks, e);
+ }
+ },
+ ),
+ { numRuns: 1_000, verbose: 2, examples: [] },
+ );
+});
+
+// *** protobufLengthPrefixUnframeStream maxMessageSize guard *** //
+test("fuzz protobufLengthPrefixUnframeStream with maxMessageSize", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(fc.uint8Array({ minLength: 1, maxLength: 64 }), {
+ minLength: 1,
+ maxLength: 8,
+ }),
+ fc.integer({ min: 1, max: 128 }),
+ async (chunks, maxMessageSize) => {
+ try {
+ await pipeline([
+ createReadableStream(chunks),
+ protobufLengthPrefixUnframeStream({ maxMessageSize }),
+ ]);
+ } catch (e) {
+ catchError(chunks, e);
+ }
+ },
+ ),
+ { numRuns: 1_000, verbose: 2, examples: [] },
+ );
+});
+
+// *** frame→unframe round-trip with random payloads *** //
+test("fuzz frame→unframe round-trip with random payloads", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.array(fc.uint8Array({ minLength: 0, maxLength: 128 }), {
+ minLength: 1,
+ maxLength: 16,
+ }),
+ async (payloads) => {
+ try {
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream(payloads),
+ protobufLengthPrefixFrameStream(),
+ ]),
+ );
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(framed),
+ protobufLengthPrefixUnframeStream(),
+ ]),
+ );
+ if (unframed.length !== payloads.length) {
+ throw new Error(
+ `Length mismatch: got ${unframed.length}, want ${payloads.length}`,
+ );
+ }
+ for (let i = 0; i < payloads.length; i++) {
+ const got = unframed[i];
+ const want = payloads[i];
+ if (got.length !== want.length) {
+ throw new Error(
+ `Payload length mismatch at ${i}: got ${got.length}, want ${want.length}`,
+ );
+ }
+ for (let j = 0; j < want.length; j++) {
+ if (got[j] !== want[j]) {
+ throw new Error(`Byte mismatch at ${i}[${j}]`);
+ }
+ }
+ }
+ } catch (e) {
+ catchError(payloads, e);
+ }
+ },
+ ),
+ { numRuns: 1_000, verbose: 2, examples: [] },
+ );
+});
diff --git a/packages/protobuf/index.js b/packages/protobuf/index.js
new file mode 100644
index 0000000..963c77b
--- /dev/null
+++ b/packages/protobuf/index.js
@@ -0,0 +1,184 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import { createTransformStream } from "@datastream/core";
+
+// Resolve a ProtobufTypeInput once: a static Type is returned as-is (the hot
+// path skips recomputation), while a function is invoked per chunk and may
+// return either a Type or a Promise.
+const makeResolveType = (Type) => {
+ if (typeof Type === "function") {
+ return (chunk) => Type(chunk);
+ }
+ return () => Type;
+};
+
+export const protobufEncodeStream = ({ Type } = {}, streamOptions = {}) => {
+ const resolveType = makeResolveType(Type);
+ const transform = async (chunk, enqueue) => {
+ const type = await resolveType(chunk);
+ enqueue(type.encode(type.create(chunk)).finish());
+ };
+ return createTransformStream(transform, streamOptions);
+};
+
+export const protobufDecodeStream = (
+ { Type, payload, maxOutputSize = Number.POSITIVE_INFINITY } = {},
+ streamOptions = {},
+) => {
+ const resolveType = makeResolveType(Type);
+ const getPayload = payload ?? ((chunk) => chunk);
+ let inputSize = 0;
+ const transform = async (chunk, enqueue) => {
+ const bytes = getPayload(chunk);
+ // Default ceiling is Infinity, so the guard is the sole gate and the
+ // comparison stays load-bearing (no redundant null check to mutate away).
+ inputSize += bytes.length;
+ if (inputSize > maxOutputSize) {
+ throw new Error(
+ `Protobuf decode input exceeds maxOutputSize (${maxOutputSize} bytes)`,
+ );
+ }
+ const type = await resolveType(chunk);
+ enqueue(type.decode(bytes));
+ };
+ return createTransformStream(transform, streamOptions);
+};
+
+// Base-128 varint, matching the protobuf length-delimited wire format. Uses
+// arithmetic (not bitwise) so lengths beyond 2^31 encode/decode correctly.
+const encodeVarint = (value) => {
+ const bytes = [];
+ let v = value;
+ while (v > 0x7f) {
+ bytes.push((v % 0x80) | 0x80);
+ v = Math.floor(v / 0x80);
+ }
+ bytes.push(v);
+ return Uint8Array.from(bytes);
+};
+
+export const protobufLengthPrefixFrameStream = (
+ _options = {},
+ streamOptions = {},
+) => {
+ const transform = (chunk, enqueue) => {
+ const prefix = encodeVarint(chunk.length);
+ const framed = new Uint8Array(prefix.length + chunk.length);
+ framed.set(prefix, 0);
+ framed.set(chunk, prefix.length);
+ enqueue(framed);
+ };
+ return createTransformStream(transform, streamOptions);
+};
+
+export const protobufLengthPrefixUnframeStream = (
+ { maxMessageSize = Number.POSITIVE_INFINITY } = {},
+ streamOptions = {},
+) => {
+ // Buffer incoming chunks as a list instead of one growable byte buffer: the
+ // varint length prefix is parsed across chunk boundaries and each message is
+ // copied out exactly once. This keeps the hot path free of per-chunk realloc
+ // and — just as importantly — free of capacity-reuse branches whose only effect
+ // is speed (and which mutation testing cannot distinguish from the slow path).
+ const pending = [];
+ let pendingLen = 0;
+
+ // Byte at absolute position `pos` (0 <= pos < pendingLen) across the chunks.
+ const byteAt = (pos) => {
+ let index = 0;
+ while (pos >= pending[index].byteLength) {
+ pos -= pending[index].byteLength;
+ index += 1;
+ }
+ return pending[index][pos];
+ };
+
+ // Drop the first `count` bytes off the front of the buffered chunks.
+ const skip = (count) => {
+ while (count > 0) {
+ const head = pending[0];
+ const n = Math.min(head.byteLength, count);
+ count -= n;
+ pendingLen -= n;
+ const rest = head.subarray(n);
+ if (rest.byteLength === 0) {
+ pending.shift();
+ } else {
+ pending[0] = rest;
+ }
+ }
+ };
+
+ // Copy the first `count` bytes off the front of the buffered chunks into a new
+ // contiguous Uint8Array, consuming them.
+ const take = (count) => {
+ const out = new Uint8Array(count);
+ let filled = 0;
+ while (filled < count) {
+ const head = pending[0];
+ const n = Math.min(head.byteLength, count - filled);
+ out.set(head.subarray(0, n), filled);
+ filled += n;
+ const rest = head.subarray(n);
+ if (rest.byteLength === 0) {
+ pending.shift();
+ } else {
+ pending[0] = rest;
+ }
+ }
+ pendingLen -= count;
+ return out;
+ };
+
+ // Pull one complete length-prefixed message off the front, or return null when
+ // the buffered bytes don't yet hold a full prefix + body.
+ const readMessage = () => {
+ let length = 0;
+ let scale = 1;
+ let prefixBytes = 0;
+ let complete = false;
+ while (prefixBytes < pendingLen) {
+ const byte = byteAt(prefixBytes);
+ length += (byte & 0x7f) * scale;
+ prefixBytes += 1;
+ if ((byte & 0x80) === 0) {
+ complete = true;
+ break;
+ }
+ scale *= 0x80;
+ }
+ if (!complete) {
+ return null;
+ }
+ if (length > maxMessageSize) {
+ throw new Error(
+ `Protobuf message exceeds maxMessageSize (${maxMessageSize} bytes)`,
+ );
+ }
+ if (pendingLen - prefixBytes < length) {
+ return null;
+ }
+ skip(prefixBytes);
+ return take(length);
+ };
+
+ const transform = (chunk, enqueue) => {
+ pending.push(chunk);
+ pendingLen += chunk.byteLength;
+ let message = readMessage();
+ while (message !== null) {
+ enqueue(message);
+ message = readMessage();
+ }
+ };
+
+ const flush = () => {
+ if (pendingLen > 0) {
+ throw new Error(
+ "Protobuf unframe stream ended with an incomplete message",
+ );
+ }
+ };
+
+ return createTransformStream(transform, flush, streamOptions);
+};
diff --git a/packages/protobuf/index.perf.js b/packages/protobuf/index.perf.js
new file mode 100644
index 0000000..ec92f39
--- /dev/null
+++ b/packages/protobuf/index.perf.js
@@ -0,0 +1,124 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import test from "node:test";
+import {
+ createReadableStream,
+ pipejoin,
+ streamToArray,
+} from "@datastream/core";
+import {
+ protobufDecodeStream,
+ protobufEncodeStream,
+ protobufLengthPrefixFrameStream,
+ protobufLengthPrefixUnframeStream,
+} from "@datastream/protobuf";
+import protobuf from "protobufjs";
+import { Bench } from "tinybench";
+
+// -- Data generators --
+
+const time = Number(process.env.BENCH_TIME ?? 5_000);
+const ITEMS = 10_000;
+
+const Type = new protobuf.Type("Msg")
+ .add(new protobuf.Field("id", 1, "int32"))
+ .add(new protobuf.Field("name", 2, "string"))
+ .add(new protobuf.Field("value", 3, "double"));
+
+const objects = Array.from({ length: ITEMS }, (_, i) => ({
+ id: i,
+ name: `item_${i}`,
+ value: i * 1.5,
+}));
+
+// Pre-encoded buffers for the decode/unframe benchmarks.
+const encoded = await streamToArray(
+ pipejoin([createReadableStream(objects), protobufEncodeStream({ Type })]),
+);
+const framed = await streamToArray(
+ pipejoin([createReadableStream(encoded), protobufLengthPrefixFrameStream()]),
+);
+
+// -- Tests --
+
+test("perf: protobufEncodeStream", async () => {
+ const bench = new Bench({ name: "protobufEncodeStream", time });
+
+ bench.add(`${ITEMS} objects`, async () => {
+ const streams = [
+ createReadableStream(objects),
+ protobufEncodeStream({ Type }),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: protobufDecodeStream", async () => {
+ const bench = new Bench({ name: "protobufDecodeStream", time });
+
+ bench.add(`${ITEMS} messages`, async () => {
+ const streams = [
+ createReadableStream(encoded),
+ protobufDecodeStream({ Type }),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: protobuf roundtrip (encode → decode)", async () => {
+ const bench = new Bench({ name: "protobuf roundtrip", time });
+
+ bench.add(`${ITEMS} objects`, async () => {
+ const streams = [
+ createReadableStream(objects),
+ protobufEncodeStream({ Type }),
+ protobufDecodeStream({ Type }),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: protobuf length-prefix framing roundtrip", async () => {
+ const bench = new Bench({ name: "protobuf framing roundtrip", time });
+
+ bench.add(`${ITEMS} messages`, async () => {
+ const streams = [
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream(),
+ protobufLengthPrefixUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: protobufLengthPrefixUnframeStream", async () => {
+ const bench = new Bench({ name: "protobufLengthPrefixUnframeStream", time });
+
+ bench.add(`${ITEMS} framed messages`, async () => {
+ const streams = [
+ createReadableStream(framed),
+ protobufLengthPrefixUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
diff --git a/packages/protobuf/index.test.js b/packages/protobuf/index.test.js
new file mode 100644
index 0000000..c3d179b
--- /dev/null
+++ b/packages/protobuf/index.test.js
@@ -0,0 +1,348 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import { deepStrictEqual, ok, strictEqual } from "node:assert";
+import test from "node:test";
+import {
+ createReadableStream,
+ pipejoin,
+ pipeline,
+ streamToArray,
+} from "@datastream/core";
+import {
+ protobufDecodeStream,
+ protobufEncodeStream,
+ protobufLengthPrefixFrameStream,
+ protobufLengthPrefixUnframeStream,
+} from "@datastream/protobuf";
+import protobuf from "protobufjs";
+
+let variant = "unknown";
+for (const execArgv of process.execArgv) {
+ const flag = "--conditions=";
+ if (execArgv.includes("--conditions=")) {
+ variant = execArgv.replace(flag, "");
+ }
+}
+
+const Type = new protobuf.Type("Msg")
+ .add(new protobuf.Field("id", 1, "int32"))
+ .add(new protobuf.Field("name", 2, "string"));
+
+const messages = [
+ { id: 1, name: "alpha" },
+ { id: 2, name: "beta" },
+ { id: 3, name: "gamma" },
+];
+
+// A message whose encoded form is >127 bytes, forcing a multi-byte varint
+// length prefix (exercises the encodeVarint loop and split-prefix framing).
+const bigMessage = { id: 42, name: "x".repeat(200) };
+
+const toPlain = (m) => ({ id: m.id, name: m.name });
+
+const encodeAll = (input, options) =>
+ streamToArray(
+ pipejoin([createReadableStream(input), protobufEncodeStream(options)]),
+ );
+
+const concat = (chunks) => {
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const c of chunks) {
+ out.set(c, offset);
+ offset += c.length;
+ }
+ return out;
+};
+
+// *** protobufEncodeStream *** //
+test(`${variant}: protobufEncodeStream encodes objects to bytes (static Type)`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ strictEqual(encoded.length, 3);
+ for (const bytes of encoded) {
+ strictEqual(bytes instanceof Uint8Array, true);
+ }
+ // Round-trips back to the originals.
+ deepStrictEqual(
+ encoded.map((b) => toPlain(Type.decode(b))),
+ messages,
+ );
+});
+
+test(`${variant}: protobufEncodeStream accepts a sync function Type`, async () => {
+ const encoded = await encodeAll(messages, { Type: () => Type });
+ deepStrictEqual(
+ encoded.map((b) => toPlain(Type.decode(b))),
+ messages,
+ );
+});
+
+test(`${variant}: protobufEncodeStream accepts an async function Type`, async () => {
+ const encoded = await encodeAll(messages, { Type: async () => Type });
+ deepStrictEqual(
+ encoded.map((b) => toPlain(Type.decode(b))),
+ messages,
+ );
+});
+
+test(`${variant}: protobufEncodeStream honors streamOptions`, async () => {
+ const encoded = await encodeAll(messages, { Type }, {});
+ const stream = protobufEncodeStream({ Type }, { highWaterMark: 1 });
+ strictEqual(typeof stream.pipe, "function");
+ strictEqual(encoded.length, 3);
+});
+
+test(`${variant}: protobufEncodeStream constructs with no arguments`, () => {
+ const stream = protobufEncodeStream();
+ strictEqual(typeof stream.pipe, "function");
+});
+
+// *** protobufDecodeStream *** //
+test(`${variant}: protobufDecodeStream decodes bytes to objects`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ const decoded = await streamToArray(
+ pipejoin([createReadableStream(encoded), protobufDecodeStream({ Type })]),
+ );
+ deepStrictEqual(decoded.map(toPlain), messages);
+});
+
+test(`${variant}: protobufDecodeStream extracts bytes via payload`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ const wrapped = encoded.map((data) => ({ data }));
+ const decoded = await streamToArray(
+ pipejoin([
+ createReadableStream(wrapped),
+ protobufDecodeStream({ Type, payload: (chunk) => chunk.data }),
+ ]),
+ );
+ deepStrictEqual(decoded.map(toPlain), messages);
+});
+
+test(`${variant}: protobufDecodeStream allows input within maxOutputSize`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ const decoded = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufDecodeStream({ Type, maxOutputSize: 1024 }),
+ ]),
+ );
+ deepStrictEqual(decoded.map(toPlain), messages);
+});
+
+test(`${variant}: protobufDecodeStream allows input exactly at maxOutputSize`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ // Cumulative input size equal to the ceiling must be accepted (boundary is
+ // strictly-greater, not greater-or-equal).
+ const exact = encoded.reduce((sum, b) => sum + b.length, 0);
+ const decoded = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufDecodeStream({ Type, maxOutputSize: exact }),
+ ]),
+ );
+ deepStrictEqual(decoded.map(toPlain), messages);
+});
+
+test(`${variant}: protobufDecodeStream throws when input exceeds maxOutputSize`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ try {
+ await pipeline([
+ createReadableStream(encoded),
+ protobufDecodeStream({ Type, maxOutputSize: 1 }),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("maxOutputSize"));
+ }
+});
+
+test(`${variant}: protobufDecodeStream constructs with no arguments`, () => {
+ const stream = protobufDecodeStream();
+ strictEqual(typeof stream.pipe, "function");
+});
+
+// *** length-prefix framing *** //
+test(`${variant}: frame then unframe round-trips messages (one chunk each)`, async () => {
+ const encoded = await encodeAll(messages, { Type });
+ const frames = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream(),
+ ]),
+ );
+ strictEqual(frames.length, 3);
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(frames),
+ protobufLengthPrefixUnframeStream(),
+ ]),
+ );
+ deepStrictEqual(
+ unframed.map((b) => toPlain(Type.decode(b))),
+ messages,
+ );
+});
+
+test(`${variant}: frame uses a single-byte varint prefix for a 127-byte message`, async () => {
+ // 127 (0x7f) is the largest value that fits in a single varint byte; the
+ // encodeVarint loop must stop at v > 0x7f (a >= boundary would spill into a
+ // second 0x80-continuation byte).
+ const body = new Uint8Array(127).fill(7);
+ const frames = await streamToArray(
+ pipejoin([createReadableStream([body]), protobufLengthPrefixFrameStream()]),
+ );
+ strictEqual(frames.length, 1);
+ strictEqual(frames[0].length, 128); // prefix(1) + body(127)
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(frames),
+ protobufLengthPrefixUnframeStream(),
+ ]),
+ );
+ deepStrictEqual(unframed, [body]);
+});
+
+test(`${variant}: unframe allows a message exactly at maxMessageSize`, async () => {
+ // A message whose length equals the ceiling must pass (boundary is
+ // strictly-greater).
+ const body = new Uint8Array(50).fill(3);
+ const frames = await streamToArray(
+ pipejoin([createReadableStream([body]), protobufLengthPrefixFrameStream()]),
+ );
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(frames),
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 50 }),
+ ]),
+ );
+ deepStrictEqual(unframed, [body]);
+});
+
+test(`${variant}: unframe reassembles messages split across byte-sized chunks`, async () => {
+ const encoded = await encodeAll([bigMessage, ...messages], { Type });
+ const frames = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream(),
+ ]),
+ );
+ // One contiguous buffer, then feed it one byte at a time so that both the
+ // multi-byte length prefix and the message body straddle chunk boundaries.
+ const stream = concat(frames);
+ const byteChunks = Array.from(stream, (b) => Uint8Array.of(b));
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(byteChunks),
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 4096 }),
+ ]),
+ );
+ deepStrictEqual(
+ unframed.map((b) => toPlain(Type.decode(b))),
+ [bigMessage, ...messages],
+ );
+});
+
+test(`${variant}: unframe reassembles a body split mid-chunk with trailing frames`, async () => {
+ // Two chunks where the split falls inside bigMessage's body, so a single
+ // message is assembled from a partial first chunk plus a second chunk that
+ // also carries the following frames. This drives the multi-chunk copy path:
+ // the body spans two buffered chunks and the second chunk's tail (the next
+ // frames) must be retained, not discarded.
+ const encoded = await encodeAll([bigMessage, ...messages], { Type });
+ const frames = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream(),
+ ]),
+ );
+ const stream = concat(frames);
+ // 100 lands inside bigMessage's body (2-byte prefix + ~200-byte body), and the
+ // remainder carries the rest of that body plus all three trailing frames.
+ const splitAt = 100;
+ const chunks = [stream.subarray(0, splitAt), stream.subarray(splitAt)];
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(chunks),
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 4096 }),
+ ]),
+ );
+ deepStrictEqual(
+ unframed.map((b) => toPlain(Type.decode(b))),
+ [bigMessage, ...messages],
+ );
+});
+
+test(`${variant}: unframe reassembles many small messages fed one byte at a time`, async () => {
+ // Many small messages, fed one byte at a time, so every prefix and body
+ // straddles chunk boundaries and the buffered-chunk list is repeatedly drained
+ // down to a partial message and refilled.
+ const small = Array.from({ length: 20 }, (_, i) => ({
+ id: i,
+ name: `m${i}`,
+ }));
+ const encoded = await encodeAll(small, { Type });
+ const frames = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream(),
+ ]),
+ );
+ const byteChunks = Array.from(concat(frames), (b) => Uint8Array.of(b));
+ const unframed = await streamToArray(
+ pipejoin([
+ createReadableStream(byteChunks),
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 4096 }),
+ ]),
+ );
+ deepStrictEqual(
+ unframed.map((b) => toPlain(Type.decode(b))),
+ small,
+ );
+});
+
+test(`${variant}: unframe throws when a message exceeds maxMessageSize`, async () => {
+ const encoded = await encodeAll([bigMessage], { Type });
+ const frames = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream(),
+ ]),
+ );
+ try {
+ await pipeline([
+ createReadableStream(frames),
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 8 }),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("maxMessageSize"));
+ }
+});
+
+test(`${variant}: unframe throws when the stream ends mid-message`, async () => {
+ // Prefix declares 5 bytes but only 2 follow.
+ const truncated = Uint8Array.of(5, 1, 2);
+ try {
+ await pipeline([
+ createReadableStream([truncated]),
+ protobufLengthPrefixUnframeStream(),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("incomplete message"));
+ }
+});
+
+test(`${variant}: frame honors streamOptions and constructs with no arguments`, async () => {
+ const stream = protobufLengthPrefixFrameStream(undefined, {});
+ strictEqual(typeof stream.pipe, "function");
+ const encoded = await encodeAll(messages, { Type });
+ const frames = await streamToArray(
+ pipejoin([
+ createReadableStream(encoded),
+ protobufLengthPrefixFrameStream({}, {}),
+ ]),
+ );
+ strictEqual(frames.length, 3);
+});
diff --git a/packages/protobuf/index.tst.ts b/packages/protobuf/index.tst.ts
new file mode 100644
index 0000000..4d3a1df
--- /dev/null
+++ b/packages/protobuf/index.tst.ts
@@ -0,0 +1,66 @@
+///
+///
+import type { ProtobufType, ProtobufTypeInput } from "@datastream/protobuf";
+import {
+ protobufDecodeStream,
+ protobufEncodeStream,
+ protobufLengthPrefixFrameStream,
+ protobufLengthPrefixUnframeStream,
+} from "@datastream/protobuf";
+import { describe, expect, test } from "tstyche";
+
+const Type: ProtobufType = {
+ encode: () => ({ finish: () => new Uint8Array() }),
+ decode: () => ({}),
+ create: () => ({}),
+};
+
+describe("ProtobufTypeInput", () => {
+ test("accepts a static Type", () => {
+ expect().type.toBeAssignableTo();
+ });
+
+ test("accepts a resolver function", () => {
+ expect<
+ (chunk: unknown) => ProtobufType
+ >().type.toBeAssignableTo();
+ });
+});
+
+describe("protobufEncodeStream", () => {
+ test("requires Type", () => {
+ expect(protobufEncodeStream({ Type })).type.not.toBeAssignableTo();
+ });
+});
+
+describe("protobufDecodeStream", () => {
+ test("requires Type", () => {
+ expect(protobufDecodeStream({ Type })).type.not.toBeAssignableTo();
+ });
+
+ test("accepts payload extractor and maxOutputSize", () => {
+ expect(
+ protobufDecodeStream<{ data: Uint8Array }>({
+ Type,
+ payload: (chunk) => chunk.data,
+ maxOutputSize: 1024,
+ }),
+ ).type.not.toBeAssignableTo();
+ });
+});
+
+describe("protobufLengthPrefixFrameStream", () => {
+ test("accepts no options", () => {
+ expect(
+ protobufLengthPrefixFrameStream(),
+ ).type.not.toBeAssignableTo();
+ });
+});
+
+describe("protobufLengthPrefixUnframeStream", () => {
+ test("accepts maxMessageSize", () => {
+ expect(
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 1024 }),
+ ).type.not.toBeAssignableTo();
+ });
+});
diff --git a/packages/protobuf/package.json b/packages/protobuf/package.json
new file mode 100644
index 0000000..38d3936
--- /dev/null
+++ b/packages/protobuf/package.json
@@ -0,0 +1,81 @@
+{
+ "name": "@datastream/protobuf",
+ "version": "0.6.1",
+ "description": "Protobuf encode/decode and length-prefix framing transform streams",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "engineStrict": true,
+ "publishConfig": {
+ "access": "public"
+ },
+ "main": "./index.node.mjs",
+ "module": "./index.web.mjs",
+ "exports": {
+ ".": {
+ "node": {
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.node.mjs"
+ }
+ },
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.web.mjs"
+ }
+ },
+ "./webstream": {
+ "types": "./index.d.ts",
+ "default": "./index.web.mjs"
+ }
+ },
+ "types": "index.d.ts",
+ "files": [
+ "*.mjs",
+ "*.map",
+ "*.d.ts"
+ ],
+ "scripts": {
+ "test": "npm run test:unit",
+ "test:unit": "node --test",
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
+ },
+ "license": "MIT",
+ "keywords": [
+ "protobuf",
+ "protobufjs",
+ "encode",
+ "decode",
+ "Web Stream API",
+ "Node Stream API"
+ ],
+ "author": {
+ "name": "datastream contributors",
+ "url": "https://github.com/willfarrell/datastream/graphs/contributors"
+ },
+ "repository": {
+ "type": "git",
+ "url": "github:willfarrell/datastream",
+ "directory": "packages/protobuf"
+ },
+ "bugs": {
+ "url": "https://github.com/willfarrell/datastream/issues"
+ },
+ "homepage": "https://datastream.js.org",
+ "dependencies": {
+ "@datastream/core": "0.6.1"
+ },
+ "devDependencies": {
+ "protobufjs": "^8.0.0"
+ },
+ "peerDependencies": {
+ "protobufjs": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "protobufjs": {
+ "optional": true
+ }
+ }
+}
diff --git a/packages/schema-registry/README.md b/packages/schema-registry/README.md
new file mode 100644
index 0000000..3c2a113
--- /dev/null
+++ b/packages/schema-registry/README.md
@@ -0,0 +1,52 @@
+
+ <datastream> `schema-registry`
+
+ Confluent and Glue Schema Registry framing streams.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+You can read the documentation at: https://datastream.js.org
+
+
+
+## Install
+
+To install datastream you can use NPM:
+
+```bash
+npm install --save @datastream/schema-registry
+```
+
+
+## Documentation and examples
+
+For documentation and examples, refer to the main [datastream monorepo on GitHub](https://github.com/willfarrell/datastream) or [datastream official website](https://datastream.js.org).
+
+
+## Contributing
+
+Everyone is very welcome to contribute to this repository. Feel free to [raise issues](https://github.com/willfarrell/datastream/issues) or to [submit Pull Requests](https://github.com/willfarrell/datastream/pulls).
+
+
+## License
+
+Licensed under [MIT License](LICENSE). Copyright (c) 2026 [will Farrell](https://github.com/willfarrell), and [datastream contributors](https://github.com/willfarrell/datastream/graphs/contributors).
\ No newline at end of file
diff --git a/packages/schema-registry/index.d.ts b/packages/schema-registry/index.d.ts
new file mode 100644
index 0000000..cff329c
--- /dev/null
+++ b/packages/schema-registry/index.d.ts
@@ -0,0 +1,54 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import type {
+ DatastreamTransform,
+ ResultStream,
+ StreamOptions,
+} from "@datastream/core";
+
+export interface ConfluentSchemaIdResult {
+ schemaId: number | null;
+}
+
+export interface ConfluentEnvelope {
+ schemaId: number;
+ payload: Uint8Array;
+}
+
+export interface GlueSchemaResult {
+ schemaVersionId: string | null;
+ compression: "none" | "zlib" | null;
+}
+
+export interface GlueEnvelope {
+ schemaVersionId: string;
+ compression: "none" | "zlib";
+ payload: Uint8Array;
+}
+
+export function confluentFrameStream(
+ options: { schemaId: number; resultKey?: string },
+ streamOptions?: StreamOptions,
+): DatastreamTransform &
+ ResultStream;
+
+export function confluentUnframeStream(
+ options?: { resultKey?: string },
+ streamOptions?: StreamOptions,
+): DatastreamTransform &
+ ResultStream;
+
+export function glueFrameStream(
+ options: {
+ schemaVersionId: string;
+ compression?: "none" | "zlib";
+ resultKey?: string;
+ },
+ streamOptions?: StreamOptions,
+): DatastreamTransform & ResultStream;
+
+export function glueUnframeStream(
+ options?: { maxDecompressedBytes?: number; resultKey?: string },
+ streamOptions?: StreamOptions,
+): DatastreamTransform &
+ ResultStream;
diff --git a/packages/schema-registry/index.fuzz.js b/packages/schema-registry/index.fuzz.js
new file mode 100644
index 0000000..653c114
--- /dev/null
+++ b/packages/schema-registry/index.fuzz.js
@@ -0,0 +1,242 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import test from "node:test";
+import {
+ createReadableStream,
+ pipejoin,
+ pipeline,
+ streamToArray,
+} from "@datastream/core";
+import {
+ confluentFrameStream,
+ confluentUnframeStream,
+ glueFrameStream,
+ glueUnframeStream,
+} from "@datastream/schema-registry";
+import fc from "fast-check";
+
+const catchError = (input, e) => {
+ const expectedErrors = [
+ // confluentUnframeStream validation
+ "confluentUnframeStream: missing 0x00 magic byte / frame is too short",
+ // glueUnframeStream validation
+ "glueUnframeStream: missing 0x03 magic byte / frame is too short",
+ // glueUnframeStream compression errors (dynamic message with hex byte)
+ "unsupported compression",
+ // glueUnframeStream decompressed size guard
+ "maxDecompressedBytes",
+ // glueFrameStream frame size guard
+ "maxFrameBytes",
+ // confluentFrameStream schemaId validation
+ "confluentFrameStream: schemaId must be an unsigned 32-bit integer",
+ // glueFrameStream schemaVersionId validation
+ "glueFrameStream: schemaVersionId required",
+ // glueFrameStream UUID validation
+ "UUID",
+ // glueFrameStream compression validation
+ "unsupported compression",
+ // asBytes chunk type validation
+ "schema-registry: chunk must be a Uint8Array, ArrayBuffer view, ArrayBuffer, or string",
+ // confluentUnframeStream.result() / glueUnframeStream.result() distinct-id guard
+ "distinct",
+ // zlib decompression failures (malformed payload)
+ "unexpected end of file",
+ "invalid stored block lengths",
+ "incorrect header check",
+ "invalid block type",
+ "invalid distance too far back",
+ "invalid literal/length code",
+ "invalid distance code",
+ "too many length or distance symbols",
+ "invalid code lengths set",
+ "invalid bit length repeat",
+ "over-subscribed dynamic bit lengths tree",
+ "incomplete dynamic bit lengths tree",
+ "empty distance tree with lengths",
+ "over-subscribed literal/length tree",
+ "incomplete literal/length tree",
+ ];
+ for (const msg of expectedErrors) {
+ if (e.message.includes(msg)) return;
+ }
+ console.error(input, e);
+ throw e;
+};
+
+// *** confluentFrameStream *** //
+test("fuzz confluentFrameStream w/ random payload + schemaId", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ fc.integer({ min: 0, max: 0xffffffff }),
+ async (payload, schemaId) => {
+ try {
+ const streams = [
+ createReadableStream([payload]),
+ confluentFrameStream({ schemaId }),
+ ];
+ await streamToArray(pipejoin(streams));
+ } catch (e) {
+ catchError({ payload, schemaId }, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// *** confluentUnframeStream *** //
+test("fuzz confluentUnframeStream w/ random bytes", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ async (input) => {
+ try {
+ await pipeline([
+ createReadableStream([input]),
+ confluentUnframeStream(),
+ ]);
+ } catch (e) {
+ catchError(input, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// *** confluent frame -> unframe roundtrip *** //
+test("fuzz confluent frame -> unframe roundtrip", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ fc.integer({ min: 0, max: 0xffffffff }),
+ async (payload, schemaId) => {
+ try {
+ const streams = [
+ createReadableStream([payload]),
+ confluentFrameStream({ schemaId }),
+ confluentUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ } catch (e) {
+ catchError({ payload, schemaId }, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// *** glueFrameStream *** //
+test("fuzz glueFrameStream w/ random payload + UUID", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ fc.uuid(),
+ async (payload, schemaVersionId) => {
+ try {
+ const streams = [
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId }),
+ ];
+ await streamToArray(pipejoin(streams));
+ } catch (e) {
+ catchError({ payload, schemaVersionId }, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// *** glueUnframeStream *** //
+test("fuzz glueUnframeStream w/ random bytes", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ async (input) => {
+ try {
+ await pipeline([createReadableStream([input]), glueUnframeStream()]);
+ } catch (e) {
+ catchError(input, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// *** glue frame -> unframe roundtrip (none compression) *** //
+test("fuzz glue frame -> unframe roundtrip (none)", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ fc.uuid(),
+ async (payload, schemaVersionId) => {
+ try {
+ const streams = [
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId }),
+ glueUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ } catch (e) {
+ catchError({ payload, schemaVersionId }, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
+
+// *** glue frame -> unframe roundtrip (zlib compression) *** //
+test("fuzz glue frame -> unframe roundtrip (zlib)", async () => {
+ await fc.assert(
+ fc.asyncProperty(
+ fc.uint8Array({ minLength: 0, maxLength: 256 }),
+ fc.uuid(),
+ async (payload, schemaVersionId) => {
+ try {
+ const streams = [
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId, compression: "zlib" }),
+ glueUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ } catch (e) {
+ catchError({ payload, schemaVersionId }, e);
+ }
+ },
+ ),
+ {
+ numRuns: 1_000,
+ verbose: 2,
+ examples: [],
+ },
+ );
+});
diff --git a/packages/schema-registry/index.js b/packages/schema-registry/index.js
new file mode 100644
index 0000000..05e7f14
--- /dev/null
+++ b/packages/schema-registry/index.js
@@ -0,0 +1,380 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+/* global CompressionStream, DecompressionStream */
+import { createTransformStream } from "@datastream/core";
+
+// Each frame/unframe transform treats one input chunk as one Schema Registry
+// envelope. This matches the Kafka use case (one Kafka message = one chunk =
+// one Schema Registry-framed record). Callers piping concatenated framed
+// buffers must split them upstream (e.g. via a length-prefix transform).
+//
+// Unframe streams emit { schemaId | schemaVersionId, payload } envelopes
+// downstream so downstream decoders (e.g. protobufDecodeStream) can pick the
+// right schema per chunk without sharing mutable state via `.result()`. The
+// `.result()` accessor is still exposed for parity with the csvDetect pattern,
+// but reflects the *most recently seen* envelope and is racy under
+// backpressure — prefer the per-chunk envelope when wiring a decoder.
+
+const GLUE_COMPRESSION_NONE = 0x00;
+const GLUE_COMPRESSION_ZLIB = 0x05;
+const UUID_HEX_RE = /^[0-9a-fA-F]{32}$/;
+
+// Shared no-op for swallowing secondary promise rejections in error-handling
+// catch blocks (see inflate/deflate). Using a named reference avoids creating
+// uncalled anonymous functions that inflate function-coverage miss counts.
+const noop = () => {};
+
+const asBytes = (chunk) => {
+ if (typeof chunk === "string") return new TextEncoder().encode(chunk);
+ // ArrayBuffer.isView covers typed arrays / DataView with possible byteOffset
+ // (including Uint8Array — the resulting view is byte-identical to the input).
+ if (ArrayBuffer.isView(chunk)) {
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ }
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
+ // Reject anything else (numbers, null, plain objects). The previous
+ // `new Uint8Array(chunk.buffer ?? chunk)` silently turned a number N into an
+ // N-byte zero-filled buffer instead of erroring.
+ throw new TypeError(
+ "schema-registry: chunk must be a Uint8Array, ArrayBuffer view, ArrayBuffer, or string",
+ );
+};
+
+const concat = (parts) => {
+ let total = 0;
+ for (const p of parts) total += p.byteLength;
+ const out = new Uint8Array(total);
+ let offset = 0;
+ for (const p of parts) {
+ out.set(p, offset);
+ offset += p.byteLength;
+ }
+ return out;
+};
+
+// Reassemble a single logical frame that createReadableStream may have
+// auto-chunked into multiple sub-chunks. Schema Registry envelopes carry no
+// embedded length, so we use the magic byte + minimum header length as the
+// frame-boundary signal: a chunk that begins with `magic` and is at least
+// `headerSize` bytes long starts a NEW frame; any other chunk is a
+// continuation of the frame currently being buffered. This keeps the
+// "one complete framed record per chunk" contract working (each such chunk
+// starts with the magic byte) while correctly reassembling a >chunkSize frame
+// that createReadableStream sliced into payload-only continuation chunks.
+const createFrameBuffer = (magic, headerSize) => {
+ let parts = null; // null => no frame in progress
+ return {
+ // Returns the completed previous frame (or undefined) and starts buffering
+ // the incoming chunk.
+ push(bytes) {
+ let completed;
+ const startsNewFrame =
+ bytes.byteLength >= headerSize && bytes[0] === magic;
+ if (startsNewFrame) {
+ // Emit the previously buffered frame (if any) before starting fresh.
+ // concat handles the common single-part case correctly too.
+ if (parts !== null) completed = concat(parts);
+ parts = [bytes];
+ } else {
+ // Continuation chunk. If nothing is in progress this is a malformed
+ // lead chunk; surface it to the caller as `undefined` start + push so
+ // the caller's header validation throws the right error.
+ if (parts === null) parts = [bytes];
+ else parts.push(bytes);
+ }
+ return completed;
+ },
+ flush() {
+ if (parts === null) return undefined;
+ const completed = concat(parts);
+ parts = null;
+ return completed;
+ },
+ };
+};
+
+const collectStream = async (readable, maxOutputSize, limitName) => {
+ const reader = readable.getReader();
+ const chunks = [];
+ let total = 0;
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ total += value.byteLength;
+ if (maxOutputSize != null && total > maxOutputSize) {
+ await reader.cancel();
+ throw new Error(
+ `schema-registry: ${limitName} exceeded (${maxOutputSize})`,
+ );
+ }
+ chunks.push(value);
+ }
+ return concat(chunks);
+};
+
+const inflate = async (bytes, maxOutputSize) => {
+ const ds = new DecompressionStream("deflate");
+ const writer = ds.writable.getWriter();
+ // Errors on the write side surface via the read side; we await both so
+ // failures (e.g. malformed zlib, backpressure aborts) propagate cleanly.
+ const writeP = writer.write(bytes);
+ const closeP = writer.close();
+ try {
+ const result = await collectStream(
+ ds.readable,
+ maxOutputSize,
+ "maxDecompressedBytes",
+ );
+ await writeP;
+ await closeP;
+ return result;
+ } catch (err) {
+ // Swallow writeP/closeP rejections after collect throws — read-side error
+ // is the underlying cause.
+ writeP.catch(noop);
+ closeP.catch(noop);
+ throw err;
+ }
+};
+
+const deflate = async (bytes, maxOutputSize) => {
+ const cs = new CompressionStream("deflate");
+ const writer = cs.writable.getWriter();
+ const writeP = writer.write(bytes);
+ const closeP = writer.close();
+ try {
+ const result = await collectStream(
+ cs.readable,
+ maxOutputSize,
+ "maxFrameBytes",
+ );
+ await writeP;
+ await closeP;
+ return result;
+ } catch (err) {
+ writeP.catch(noop);
+ closeP.catch(noop);
+ throw err;
+ }
+};
+
+// *** Confluent (5-byte: 0x00 magic + uint32 BE schema id) *** //
+
+export const confluentFrameStream = (
+ { schemaId, resultKey } = {},
+ streamOptions = {},
+) => {
+ if (
+ // Number.isInteger is false for every non-number, so it already rejects
+ // strings/bigints/etc.; an explicit typeof check would be redundant.
+ !Number.isInteger(schemaId) ||
+ schemaId < 0 ||
+ schemaId > 0xffffffff
+ ) {
+ throw new TypeError(
+ "confluentFrameStream: schemaId must be an unsigned 32-bit integer",
+ );
+ }
+ const header = new Uint8Array(5);
+ header[0] = 0x00;
+ new DataView(header.buffer).setUint32(1, schemaId, false);
+ const value = { schemaId };
+ const transform = (chunk, enqueue) => {
+ enqueue(concat([header, asBytes(chunk)]));
+ };
+ const stream = createTransformStream(transform, streamOptions);
+ // For frame streams `.result()` echoes the schemaId the caller configured
+ // (not detected). Listed under "confluentSchemaId" for symmetry with
+ // unframe — see file header note.
+ stream.result = () => ({
+ key: resultKey ?? "confluentSchemaId",
+ value,
+ });
+ return stream;
+};
+
+export const confluentUnframeStream = (
+ { resultKey } = {},
+ streamOptions = {},
+) => {
+ const value = { schemaId: null };
+ let distinctIds = 0;
+ const frameBuffer = createFrameBuffer(0x00, 5);
+ const parseAndEmit = (bytes, enqueue) => {
+ if (bytes.byteLength < 5 || bytes[0] !== 0x00) {
+ throw new Error(
+ "confluentUnframeStream: missing 0x00 magic byte / frame is too short",
+ );
+ }
+ const schemaId = new DataView(
+ bytes.buffer,
+ bytes.byteOffset,
+ bytes.byteLength,
+ ).getUint32(1, false);
+ if (value.schemaId !== schemaId) distinctIds += 1;
+ value.schemaId = schemaId;
+ // subarray (zero-copy) — the source bytes outlive the chunk.
+ enqueue({ schemaId, payload: bytes.subarray(5) });
+ };
+ const transform = (chunk, enqueue) => {
+ const completed = frameBuffer.push(asBytes(chunk));
+ if (completed !== undefined) parseAndEmit(completed, enqueue);
+ };
+ const flush = (enqueue) => {
+ const completed = frameBuffer.flush();
+ if (completed !== undefined) parseAndEmit(completed, enqueue);
+ };
+ const stream = createTransformStream(transform, flush, streamOptions);
+ // `.result()` reflects the most-recently-seen envelope and is racy under
+ // backpressure (see file header). If more than one DISTINCT schema id was
+ // observed, the single shared value is meaningless, so throw rather than
+ // silently report a stale id — the per-chunk envelope is the correct API.
+ stream.result = () => {
+ if (distinctIds > 1) {
+ throw new Error(
+ "confluentUnframeStream.result(): stream carried multiple distinct schemaIds; use the per-chunk envelope { schemaId, payload } instead",
+ );
+ }
+ return { key: resultKey ?? "confluentSchemaId", value };
+ };
+ return stream;
+};
+
+// *** Glue (18-byte: 0x03 magic + 1 byte compression + 16 bytes UUID) *** //
+
+const uuidToBytes = (uuid) => {
+ const hex = uuid.replaceAll("-", "");
+ if (!UUID_HEX_RE.test(hex)) {
+ throw new TypeError(
+ `glueFrameStream: schemaVersionId must be a valid UUID (got ${uuid})`,
+ );
+ }
+ // hex is exactly 32 chars (UUID_HEX_RE), i.e. 16 byte-pairs. Collect into a
+ // plain array first so an over-long loop produces a >16-byte result (caught
+ // downstream by header.set) instead of a silently-ignored out-of-bounds write
+ // on a fixed-size typed array.
+ const out = [];
+ for (let i = 0; i < 16; i++) {
+ out.push(Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16));
+ }
+ return new Uint8Array(out);
+};
+
+// Precomputed byte -> 2-char hex so bytesToUuid (called per unframed Glue
+// message) avoids a per-byte toString(16)+padStart and the 5 trailing slices:
+// index the table and concatenate the canonical 8-4-4-4-12 layout directly.
+const HEX_BYTE = Array.from({ length: 256 }, (_, i) =>
+ i.toString(16).padStart(2, "0"),
+);
+const bytesToUuid = (bytes, offset) => {
+ const h = HEX_BYTE;
+ return (
+ `${h[bytes[offset]]}${h[bytes[offset + 1]]}${h[bytes[offset + 2]]}${h[bytes[offset + 3]]}-` +
+ `${h[bytes[offset + 4]]}${h[bytes[offset + 5]]}-` +
+ `${h[bytes[offset + 6]]}${h[bytes[offset + 7]]}-` +
+ `${h[bytes[offset + 8]]}${h[bytes[offset + 9]]}-` +
+ `${h[bytes[offset + 10]]}${h[bytes[offset + 11]]}${h[bytes[offset + 12]]}${h[bytes[offset + 13]]}${h[bytes[offset + 14]]}${h[bytes[offset + 15]]}`
+ );
+};
+
+export const glueFrameStream = (
+ { schemaVersionId, compression = "none", maxFrameBytes, resultKey } = {},
+ streamOptions = {},
+) => {
+ if (typeof schemaVersionId !== "string") {
+ throw new TypeError("glueFrameStream: schemaVersionId required");
+ }
+ if (compression !== "none" && compression !== "zlib") {
+ throw new TypeError(
+ `glueFrameStream: unsupported compression "${compression}" (expected "none" or "zlib")`,
+ );
+ }
+ const uuid = uuidToBytes(schemaVersionId);
+ const compressionByte =
+ compression === "zlib" ? GLUE_COMPRESSION_ZLIB : GLUE_COMPRESSION_NONE;
+ const value = { schemaVersionId };
+ // Pre-built 18-byte header — reused across chunks (only payload differs).
+ const header = new Uint8Array(18);
+ header[0] = 0x03;
+ header[1] = compressionByte;
+ header.set(uuid, 2);
+
+ const transform = async (chunk, enqueue) => {
+ const bytes = asBytes(chunk);
+ // deflate() buffers the whole compressed result; bound it with the
+ // optional maxFrameBytes ceiling, mirroring glueUnframeStream's
+ // maxDecompressedBytes (no other buffering path here is unbounded).
+ const payload =
+ compression === "zlib" ? await deflate(bytes, maxFrameBytes) : bytes;
+ enqueue(concat([header, payload]));
+ };
+ const stream = createTransformStream(transform, streamOptions);
+ stream.result = () => ({
+ key: resultKey ?? "glueSchemaVersionId",
+ value,
+ });
+ return stream;
+};
+
+export const glueUnframeStream = (
+ { maxDecompressedBytes = 10 * 1024 * 1024, resultKey } = {},
+ streamOptions = {},
+) => {
+ const value = { schemaVersionId: null, compression: null };
+ let distinctIds = 0;
+ const frameBuffer = createFrameBuffer(0x03, 18);
+ const parseAndEmit = async (bytes, enqueue) => {
+ if (bytes.byteLength < 18 || bytes[0] !== 0x03) {
+ throw new Error(
+ "glueUnframeStream: missing 0x03 magic byte / frame is too short",
+ );
+ }
+ const compressionByte = bytes[1];
+ const schemaVersionId = bytesToUuid(bytes, 2);
+ if (value.schemaVersionId !== schemaVersionId) distinctIds += 1;
+ value.schemaVersionId = schemaVersionId;
+ const framedPayload = bytes.subarray(18);
+ let payload;
+ let kind;
+ if (compressionByte === GLUE_COMPRESSION_NONE) {
+ kind = "none";
+ payload = framedPayload;
+ } else if (compressionByte === GLUE_COMPRESSION_ZLIB) {
+ kind = "zlib";
+ payload = await inflate(framedPayload, maxDecompressedBytes);
+ } else {
+ throw new Error(
+ `glueUnframeStream: unsupported compression byte 0x${compressionByte.toString(16).padStart(2, "0")}`,
+ );
+ }
+ value.compression = kind;
+ enqueue({ schemaVersionId, compression: kind, payload });
+ };
+ const transform = async (chunk, enqueue) => {
+ const completed = frameBuffer.push(asBytes(chunk));
+ if (completed !== undefined) await parseAndEmit(completed, enqueue);
+ };
+ const flush = async (enqueue) => {
+ const completed = frameBuffer.flush();
+ if (completed !== undefined) await parseAndEmit(completed, enqueue);
+ };
+ const stream = createTransformStream(transform, flush, streamOptions);
+ // See confluentUnframeStream: throw rather than report a stale id when the
+ // stream carried multiple distinct schemaVersionIds.
+ stream.result = () => {
+ if (distinctIds > 1) {
+ throw new Error(
+ "glueUnframeStream.result(): stream carried multiple distinct schemaVersionIds; use the per-chunk envelope { schemaVersionId, compression, payload } instead",
+ );
+ }
+ return { key: resultKey ?? "glueSchemaVersionId", value };
+ };
+ return stream;
+};
+
+export default {
+ confluentFrameStream,
+ confluentUnframeStream,
+ glueFrameStream,
+ glueUnframeStream,
+};
diff --git a/packages/schema-registry/index.perf.js b/packages/schema-registry/index.perf.js
new file mode 100644
index 0000000..d366a88
--- /dev/null
+++ b/packages/schema-registry/index.perf.js
@@ -0,0 +1,142 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+import test from "node:test";
+import {
+ createReadableStream,
+ pipejoin,
+ streamToArray,
+} from "@datastream/core";
+import {
+ confluentFrameStream,
+ confluentUnframeStream,
+ glueFrameStream,
+ glueUnframeStream,
+} from "@datastream/schema-registry";
+import { Bench } from "tinybench";
+
+// -- Data generators --
+
+const time = Number(process.env.BENCH_TIME ?? 5_000);
+
+const schemaId = 42;
+const schemaVersionId = "12345678-1234-1234-1234-1234567890ab";
+const count = 10_000;
+
+// Generate N Uint8Array payloads of 64 bytes each
+const messages = Array.from({ length: count }, (_, i) => {
+ const buf = new Uint8Array(64);
+ for (let j = 0; j < 64; j++) buf[j] = (i + j) % 256;
+ return buf;
+});
+
+// Pre-frame messages for unframe benchmarks
+const confluentFramed = await streamToArray(
+ pipejoin([
+ createReadableStream(messages),
+ confluentFrameStream({ schemaId }),
+ ]),
+);
+
+const glueFramed = await streamToArray(
+ pipejoin([
+ createReadableStream(messages),
+ glueFrameStream({ schemaVersionId }),
+ ]),
+);
+
+// -- Tests --
+
+test("perf: confluentFrameStream", async () => {
+ const bench = new Bench({ name: "confluentFrameStream", time });
+
+ bench.add(`${count} messages`, async () => {
+ const streams = [
+ createReadableStream(messages),
+ confluentFrameStream({ schemaId }),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: confluentUnframeStream", async () => {
+ const bench = new Bench({ name: "confluentUnframeStream", time });
+
+ bench.add(`${count} messages`, async () => {
+ const streams = [
+ createReadableStream(confluentFramed),
+ confluentUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: confluent frame -> unframe roundtrip", async () => {
+ const bench = new Bench({ name: "confluent roundtrip", time });
+
+ bench.add(`${count} messages`, async () => {
+ const streams = [
+ createReadableStream(messages),
+ confluentFrameStream({ schemaId }),
+ confluentUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: glueFrameStream", async () => {
+ const bench = new Bench({ name: "glueFrameStream", time });
+
+ bench.add(`${count} messages`, async () => {
+ const streams = [
+ createReadableStream(messages),
+ glueFrameStream({ schemaVersionId }),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: glueUnframeStream", async () => {
+ const bench = new Bench({ name: "glueUnframeStream", time });
+
+ bench.add(`${count} messages`, async () => {
+ const streams = [createReadableStream(glueFramed), glueUnframeStream()];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
+
+test("perf: glue frame -> unframe roundtrip", async () => {
+ const bench = new Bench({ name: "glue roundtrip", time });
+
+ bench.add(`${count} messages`, async () => {
+ const streams = [
+ createReadableStream(messages),
+ glueFrameStream({ schemaVersionId }),
+ glueUnframeStream(),
+ ];
+ await streamToArray(pipejoin(streams));
+ });
+
+ await bench.run();
+ console.log(`\n${bench.name}`);
+ console.table(bench.table());
+});
diff --git a/packages/schema-registry/index.test.js b/packages/schema-registry/index.test.js
new file mode 100644
index 0000000..5756260
--- /dev/null
+++ b/packages/schema-registry/index.test.js
@@ -0,0 +1,1377 @@
+import { deepStrictEqual, ok, strictEqual } from "node:assert";
+import test from "node:test";
+
+import {
+ createReadableStream,
+ pipejoin,
+ pipeline,
+ streamToArray,
+} from "@datastream/core";
+import {
+ confluentFrameStream,
+ confluentUnframeStream,
+ glueFrameStream,
+ glueUnframeStream,
+} from "@datastream/schema-registry";
+
+let variant = "unknown";
+for (const execArgv of process.execArgv) {
+ const flag = "--conditions=";
+ if (execArgv.includes(flag)) {
+ variant = execArgv.replace(flag, "");
+ }
+}
+
+const helloWorld = new TextEncoder().encode("hello world");
+const schemaUuid = "12345678-1234-1234-1234-1234567890ab";
+
+// *** Confluent *** //
+test(`${variant}: confluentFrameStream prepends 0x00 + uint32 BE schema id`, async () => {
+ const streams = [
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 257 }),
+ ];
+ const out = await streamToArray(pipejoin(streams));
+ strictEqual(out.length, 1);
+ strictEqual(out[0][0], 0x00);
+ // 257 = 0x00000101
+ strictEqual(out[0][1], 0x00);
+ strictEqual(out[0][2], 0x00);
+ strictEqual(out[0][3], 0x01);
+ strictEqual(out[0][4], 0x01);
+ strictEqual(out[0].byteLength, 5 + helloWorld.byteLength);
+});
+
+test(`${variant}: confluentFrameStream encodes schemaId big-endian for >24-bit values`, async () => {
+ const streams = [
+ createReadableStream([new Uint8Array([0xaa])]),
+ confluentFrameStream({ schemaId: 0x12345678 }),
+ ];
+ const out = await streamToArray(pipejoin(streams));
+ deepStrictEqual(
+ Array.from(out[0].slice(0, 5)),
+ [0x00, 0x12, 0x34, 0x56, 0x78],
+ );
+});
+
+test(`${variant}: confluent frame -> unframe round-trip emits envelope with schemaId + payload`, async () => {
+ const unframe = confluentUnframeStream();
+ const streams = [
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 42 }),
+ unframe,
+ ];
+ const out = await streamToArray(pipejoin(streams));
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 42);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+ // .result() still works for compatibility with the csvDetect-style readers.
+ strictEqual(unframe.result().value.schemaId, 42);
+});
+
+test(`${variant}: confluentUnframeStream rejects frames missing the magic byte`, async () => {
+ const bogus = new Uint8Array([0x01, 0, 0, 0, 0, 0x68, 0x69]);
+ try {
+ await pipeline([createReadableStream([bogus]), confluentUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("magic byte"));
+ }
+});
+
+// *** Glue uncompressed *** //
+test(`${variant}: glueFrameStream prepends 0x03 + compression byte + uuid`, async () => {
+ const streams = [
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ];
+ const out = await streamToArray(pipejoin(streams));
+ strictEqual(out[0][0], 0x03);
+ strictEqual(out[0][1], 0x00); // compression none
+ strictEqual(out[0].byteLength, 18 + helloWorld.byteLength);
+});
+
+test(`${variant}: glue frame -> unframe round-trip emits { schemaVersionId, compression, payload }`, async () => {
+ const unframe = glueUnframeStream();
+ const streams = [
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ unframe,
+ ];
+ const out = await streamToArray(pipejoin(streams));
+ strictEqual(out[0].schemaVersionId, schemaUuid);
+ strictEqual(out[0].compression, "none");
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+ strictEqual(unframe.result().value.schemaVersionId, schemaUuid);
+ strictEqual(unframe.result().value.compression, "none");
+});
+
+// *** Glue zlib *** //
+test(`${variant}: glue frame -> unframe round-trip (zlib compressed)`, async () => {
+ const payload = new TextEncoder().encode("a".repeat(2048));
+ const unframe = glueUnframeStream();
+ const streams = [
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ unframe,
+ ];
+ const out = await streamToArray(pipejoin(streams));
+ strictEqual(out[0].compression, "zlib");
+ deepStrictEqual(Array.from(out[0].payload), Array.from(payload));
+});
+
+test(`${variant}: glueUnframeStream enforces maxDecompressedBytes`, async () => {
+ const payload = new TextEncoder().encode("a".repeat(2048));
+ try {
+ await pipeline([
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ glueUnframeStream({ maxDecompressedBytes: 100 }),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("maxDecompressedBytes"));
+ }
+});
+
+test(`${variant}: glueUnframeStream rejects unknown compression byte`, async () => {
+ const bogus = new Uint8Array(18 + 4);
+ bogus[0] = 0x03;
+ bogus[1] = 0x09; // unsupported
+ try {
+ await pipeline([createReadableStream([bogus]), glueUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("unsupported compression"));
+ }
+});
+
+// *** input validation *** //
+test(`${variant}: confluentFrameStream rejects non-integer / out-of-range schemaId`, () => {
+ for (const bad of [undefined, "1", -1, 1.5, null, 0x100000000]) {
+ try {
+ confluentFrameStream({ schemaId: bad });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("schemaId"));
+ }
+ }
+});
+
+test(`${variant}: glueFrameStream rejects missing schemaVersionId`, () => {
+ try {
+ glueFrameStream({});
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("schemaVersionId"));
+ }
+});
+
+test(`${variant}: glueFrameStream rejects unsupported compression`, () => {
+ try {
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "gzip" });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("compression"));
+ }
+});
+
+test(`${variant}: glueFrameStream rejects malformed schemaVersionId UUID`, () => {
+ for (const bad of [
+ "not-a-uuid",
+ "zzzzzzzz-1234-1234-1234-1234567890ab", // non-hex
+ "1234567812341234123412345678ab", // too short
+ ]) {
+ try {
+ glueFrameStream({ schemaVersionId: bad });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("UUID"));
+ }
+ }
+});
+
+test(`${variant}: glueUnframeStream rejects frames missing the magic byte`, async () => {
+ const bogus = new Uint8Array(20);
+ bogus[0] = 0x07;
+ try {
+ await pipeline([createReadableStream([bogus]), glueUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("magic byte"));
+ }
+});
+
+// *** asBytes input-type handling (finding: numeric chunk -> zero-filled buffer) *** //
+test(`${variant}: confluentUnframeStream accepts string chunks`, async () => {
+ // Build a framed Confluent envelope, then feed it back as a latin1 string.
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 7 }),
+ ]),
+ );
+ const asString = Array.from(framed[0])
+ .map((b) => String.fromCharCode(b))
+ .join("");
+ // Re-encode via TextEncoder would corrupt high bytes; helloWorld is ASCII so
+ // round-trips, and the header bytes are < 0x80, so a latin1 string is exact.
+ const out = await streamToArray(
+ pipejoin([createReadableStream([asString]), confluentUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 7);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+});
+
+test(`${variant}: confluentUnframeStream accepts ArrayBuffer view with non-zero byteOffset`, async () => {
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 9 }),
+ ]),
+ );
+ // Place the frame inside a larger buffer at a non-zero offset, then hand a
+ // subarray view (byteOffset > 0) to the unframe stream.
+ const backing = new Uint8Array(framed[0].byteLength + 3);
+ backing.set(framed[0], 3);
+ const view = backing.subarray(3);
+ const out = await streamToArray(
+ pipejoin([createReadableStream([view]), confluentUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 9);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+});
+
+test(`${variant}: confluentFrameStream throws on a numeric chunk instead of framing zero bytes`, async () => {
+ try {
+ await pipeline([
+ createReadableStream([5]),
+ confluentFrameStream({ schemaId: 1 }),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("chunk must be"));
+ }
+});
+
+// *** Glue zlib failure / round-trip edge cases *** //
+test(`${variant}: glueUnframeStream rejects a malformed zlib payload`, async () => {
+ // magic 0x03 + compression 0x05 (zlib) + 16-byte UUID + garbage payload.
+ const frame = new Uint8Array(18 + 4);
+ frame[0] = 0x03;
+ frame[1] = 0x05;
+ frame.set([0xde, 0xad, 0xbe, 0xef], 18);
+ try {
+ await pipeline([createReadableStream([frame]), glueUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e instanceof Error);
+ ok(!e.message.includes("Should have thrown"));
+ }
+});
+
+test(`${variant}: glue frame -> unframe round-trip (zlib, empty payload)`, async () => {
+ const empty = new Uint8Array(0);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([empty]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ glueUnframeStream(),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].compression, "zlib");
+ strictEqual(out[0].payload.byteLength, 0);
+});
+
+test(`${variant}: glueFrameStream deflate enforces maxFrameBytes ceiling`, async () => {
+ const payload = new TextEncoder().encode("a".repeat(4096));
+ try {
+ await pipeline([
+ createReadableStream([payload]),
+ glueFrameStream({
+ schemaVersionId: schemaUuid,
+ compression: "zlib",
+ maxFrameBytes: 10,
+ }),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("maxFrameBytes"));
+ }
+});
+
+// *** .result() raciness guard *** //
+test(`${variant}: confluentUnframeStream.result() throws when multiple distinct schema ids are seen`, async () => {
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 1 }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 2 }),
+ ]),
+ );
+ const unframe = confluentUnframeStream();
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ try {
+ await unframe.result();
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("distinct"));
+ }
+});
+
+// *** multi-chunk reassembly: a single frame split across chunks *** //
+test(`${variant}: confluent frame -> unframe round-trips a single frame auto-chunked over 16KB`, async () => {
+ // 40KB payload; createReadableStream auto-chunks the framed buffer into
+ // multiple 16KB sub-chunks of ONE frame.
+ const big = new Uint8Array(40 * 1024);
+ for (let i = 0; i < big.byteLength; i++) big[i] = i % 251;
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([big]),
+ confluentFrameStream({ schemaId: 123 }),
+ ]),
+ );
+ ok(framed[0].byteLength > 16384);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream(framed[0]), // ArrayBuffer path -> sub-chunks
+ confluentUnframeStream(),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 123);
+ strictEqual(out[0].payload.byteLength, big.byteLength);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(big));
+});
+
+test(`${variant}: glue frame -> unframe round-trips a single frame auto-chunked over 16KB`, async () => {
+ const big = new Uint8Array(40 * 1024);
+ for (let i = 0; i < big.byteLength; i++) big[i] = (i * 7) % 251;
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([big]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ]),
+ );
+ ok(framed[0].byteLength > 16384);
+ const out = await streamToArray(
+ pipejoin([createReadableStream(framed[0]), glueUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaVersionId, schemaUuid);
+ strictEqual(out[0].payload.byteLength, big.byteLength);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(big));
+});
+
+// *** envelope shape protects against per-message schemaVersionId drift *** //
+test(`${variant}: glueUnframeStream envelope correctly pairs payload with its own schemaVersionId`, async () => {
+ const idA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
+ const idB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([new TextEncoder().encode("payload-a")]),
+ glueFrameStream({ schemaVersionId: idA }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([new TextEncoder().encode("payload-b")]),
+ glueFrameStream({ schemaVersionId: idB }),
+ ]),
+ );
+ const envelopes = await streamToArray(
+ pipejoin([
+ createReadableStream([frameA[0], frameB[0]]),
+ glueUnframeStream(),
+ ]),
+ );
+ strictEqual(envelopes.length, 2);
+ strictEqual(envelopes[0].schemaVersionId, idA);
+ strictEqual(envelopes[1].schemaVersionId, idB);
+ deepStrictEqual(new TextDecoder().decode(envelopes[0].payload), "payload-a");
+ deepStrictEqual(new TextDecoder().decode(envelopes[1].payload), "payload-b");
+});
+
+// *** UUID regex anchoring: ^ and $ must both match (not substring) *** //
+test(`${variant}: glueFrameStream rejects a 33-char hex string (fails $ anchor on UUID regex)`, () => {
+ // After replaceAll("-",""), 33 hex chars is not 32 - both anchors must hold.
+ const tooLong = "0123456789abcdef0123456789abcdef0"; // 33 hex chars, no hyphens
+ try {
+ glueFrameStream({ schemaVersionId: tooLong });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("UUID"), `Expected UUID error, got: ${e.message}`);
+ }
+ const alsoTooLong = "012345678901234567890123456789abc"; // 33 hex chars
+ try {
+ glueFrameStream({ schemaVersionId: alsoTooLong });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("UUID"), `Expected UUID error, got: ${e.message}`);
+ }
+});
+
+// *** asBytes: ArrayBuffer.isView path (non-Uint8Array typed array) *** //
+test(`${variant}: confluentFrameStream accepts an Int16Array (ArrayBuffer.isView branch)`, async () => {
+ // Int16Array is an ArrayBuffer view but NOT a Uint8Array - hits ArrayBuffer.isView branch.
+ const int16 = new Int16Array([1, 2, 3, 4]);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([int16]),
+ confluentFrameStream({ schemaId: 55 }),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0][0], 0x00); // magic byte
+ strictEqual(out[0].byteLength, 5 + int16.byteLength);
+});
+
+// *** asBytes: plain ArrayBuffer path *** //
+test(`${variant}: confluentFrameStream accepts a plain ArrayBuffer`, async () => {
+ // Plain ArrayBuffer (not a view) hits the instanceof ArrayBuffer branch.
+ const buf = new Uint8Array([0x61, 0x62, 0x63]).buffer; // "abc"
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([buf]),
+ confluentFrameStream({ schemaId: 77 }),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0][0], 0x00);
+ strictEqual(out[0].byteLength, 5 + 3);
+ // Verify payload bytes are "abc"
+ strictEqual(out[0][5], 0x61);
+ strictEqual(out[0][6], 0x62);
+ strictEqual(out[0][7], 0x63);
+});
+
+// *** asBytes: Uint8Array fast-path returns the chunk identity *** //
+test(`${variant}: asBytes Uint8Array fast-path preserves the original bytes exactly`, async () => {
+ // Exercise the first branch in asBytes: Uint8Array -> return chunk directly.
+ const bytes = new Uint8Array([0x41, 0x42, 0x43]);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([bytes]),
+ confluentFrameStream({ schemaId: 0 }),
+ ]),
+ );
+ strictEqual(out[0][5], 0x41);
+ strictEqual(out[0][6], 0x42);
+ strictEqual(out[0][7], 0x43);
+});
+
+// *** confluentFrameStream schemaId boundary values *** //
+test(`${variant}: confluentFrameStream accepts schemaId === 0 (minimum valid, exact boundary)`, async () => {
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 0 }),
+ ]),
+ );
+ strictEqual(out[0][1], 0x00);
+ strictEqual(out[0][2], 0x00);
+ strictEqual(out[0][3], 0x00);
+ strictEqual(out[0][4], 0x00);
+});
+
+test(`${variant}: confluentFrameStream accepts schemaId === 0xffffffff (maximum valid, exact boundary)`, async () => {
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 0xffffffff }),
+ ]),
+ );
+ strictEqual(out[0][1], 0xff);
+ strictEqual(out[0][2], 0xff);
+ strictEqual(out[0][3], 0xff);
+ strictEqual(out[0][4], 0xff);
+});
+
+test(`${variant}: confluentFrameStream rejects schemaId === 0x100000000 (one above max)`, () => {
+ try {
+ confluentFrameStream({ schemaId: 0x100000000 });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("schemaId"),
+ `Expected schemaId error, got: ${e.message}`,
+ );
+ }
+});
+
+// *** confluentFrameStream typeof validation: number type check *** //
+test(`${variant}: confluentFrameStream rejects schemaId that is a number string (type check)`, () => {
+ try {
+ confluentFrameStream({ schemaId: "42" });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("schemaId"),
+ `Expected schemaId error, got: ${e.message}`,
+ );
+ }
+});
+
+// *** confluentFrameStream uses big-endian encoding (setUint32 false) *** //
+test(`${variant}: confluentFrameStream header bytes are big-endian (setUint32 little=false)`, async () => {
+ // 0x01020304 big-endian => bytes [0x01, 0x02, 0x03, 0x04]
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0xff])]),
+ confluentFrameStream({ schemaId: 0x01020304 }),
+ ]),
+ );
+ strictEqual(out[0][1], 0x01);
+ strictEqual(out[0][2], 0x02);
+ strictEqual(out[0][3], 0x03);
+ strictEqual(out[0][4], 0x04);
+});
+
+// *** confluentUnframeStream: frame exactly 4 bytes -> too short *** //
+test(`${variant}: confluentUnframeStream rejects a frame that is exactly 4 bytes (byteLength < 5)`, async () => {
+ const short = new Uint8Array([0x00, 0x00, 0x00, 0x01]); // valid magic, but only 4 bytes
+ try {
+ await pipeline([createReadableStream([short]), confluentUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("magic byte"),
+ `Expected magic byte error, got: ${e.message}`,
+ );
+ }
+});
+
+// *** confluentUnframeStream: schemaId read as big-endian (getUint32 false) *** //
+test(`${variant}: confluentUnframeStream reads schemaId as big-endian (not little-endian)`, async () => {
+ // magic 0x00 + [0x01, 0x02, 0x03, 0x04] big-endian = 0x01020304
+ const frame = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0xaa]);
+ const out = await streamToArray(
+ pipejoin([createReadableStream([frame]), confluentUnframeStream()]),
+ );
+ strictEqual(out[0].schemaId, 0x01020304);
+ strictEqual(out[0].payload[0], 0xaa);
+});
+
+// *** confluentUnframeStream distinctIds tracking *** //
+test(`${variant}: confluentUnframeStream.result() succeeds when exactly 1 distinct schemaId is seen`, async () => {
+ const unframe = confluentUnframeStream();
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 5 }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0x01])]),
+ confluentFrameStream({ schemaId: 5 }), // same id
+ ]),
+ );
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ const result = unframe.result();
+ strictEqual(result.value.schemaId, 5);
+});
+
+test(`${variant}: confluentUnframeStream.result() throws when exactly 2 distinct schemaIds are seen`, async () => {
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 10 }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 11 }),
+ ]),
+ );
+ const unframe = confluentUnframeStream();
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ try {
+ unframe.result();
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("distinct"),
+ `Expected distinct error, got: ${e.message}`,
+ );
+ ok(
+ e.message.includes("schemaId"),
+ `Expected schemaId in error, got: ${e.message}`,
+ );
+ }
+});
+
+// *** confluentUnframeStream.result() uses resultKey when provided *** //
+test(`${variant}: confluentUnframeStream.result() uses custom resultKey`, async () => {
+ const unframe = confluentUnframeStream({ resultKey: "myKey" });
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 3 }),
+ unframe,
+ ]),
+ );
+ strictEqual(unframe.result().key, "myKey");
+});
+
+test(`${variant}: confluentUnframeStream.result() defaults resultKey to "confluentSchemaId"`, async () => {
+ const unframe = confluentUnframeStream();
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 3 }),
+ unframe,
+ ]),
+ );
+ strictEqual(unframe.result().key, "confluentSchemaId");
+});
+
+// *** confluentFrameStream.result() resultKey *** //
+test(`${variant}: confluentFrameStream.result() uses custom resultKey`, async () => {
+ const stream = confluentFrameStream({
+ schemaId: 7,
+ resultKey: "myConfluentKey",
+ });
+ await streamToArray(pipejoin([createReadableStream([helloWorld]), stream]));
+ strictEqual(stream.result().key, "myConfluentKey");
+});
+
+test(`${variant}: confluentFrameStream.result() defaults to "confluentSchemaId"`, async () => {
+ const stream = confluentFrameStream({ schemaId: 7 });
+ await streamToArray(pipejoin([createReadableStream([helloWorld]), stream]));
+ strictEqual(stream.result().key, "confluentSchemaId");
+ strictEqual(stream.result().value.schemaId, 7);
+});
+
+// *** collectStream maxOutputSize boundary: total > maxOutputSize, not >= *** //
+test(`${variant}: glueUnframeStream allows decompressed output exactly equal to maxDecompressedBytes`, async () => {
+ const payload = new TextEncoder().encode("a".repeat(100));
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ glueUnframeStream({ maxDecompressedBytes: 100 }),
+ ]),
+ );
+ strictEqual(out[0].payload.byteLength, 100);
+});
+
+// *** collectStream: maxOutputSize null means no limit *** //
+test(`${variant}: glueUnframeStream with default maxDecompressedBytes handles payloads without throwing`, async () => {
+ const payload = new TextEncoder().encode("test payload");
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ glueUnframeStream(),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(payload));
+});
+
+// *** createFrameBuffer: multi-chunk frame (parts.length > 1 -> concat needed in flush) *** //
+test(`${variant}: confluentUnframeStream reassembles a frame split into exactly 2 chunks`, async () => {
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 200 }),
+ ]),
+ );
+ const full = framed[0];
+ const headerChunk = full.slice(0, 5);
+ const payloadChunk = full.slice(5);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([headerChunk, payloadChunk]),
+ confluentUnframeStream(),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 200);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+});
+
+test(`${variant}: confluentUnframeStream reassembles a frame split into 3+ chunks (multi-part concat)`, async () => {
+ const payload = new Uint8Array(12);
+ for (let i = 0; i < 12; i++) payload[i] = i + 1; // none start with 0x00
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([payload]),
+ confluentFrameStream({ schemaId: 201 }),
+ ]),
+ );
+ const full = framed[0]; // 17 bytes total
+ const c1 = full.slice(0, 5); // header
+ const c2 = full.slice(5, 9); // continuation 1
+ const c3 = full.slice(9); // continuation 2
+ const out = await streamToArray(
+ pipejoin([createReadableStream([c1, c2, c3]), confluentUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 201);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(payload));
+});
+
+// *** createFrameBuffer: push completes previous multi-part frame when new frame starts *** //
+test(`${variant}: confluentUnframeStream emits first multi-part frame when second frame header arrives`, async () => {
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0x01, 0x02])]),
+ confluentFrameStream({ schemaId: 300 }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0x03, 0x04])]),
+ confluentFrameStream({ schemaId: 301 }),
+ ]),
+ );
+ const fullA = frameA[0];
+ const a1 = fullA.slice(0, 5); // header
+ const a2 = fullA.slice(5); // continuation
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([a1, a2, frameB[0]]),
+ confluentUnframeStream(),
+ ]),
+ );
+ strictEqual(out.length, 2);
+ strictEqual(out[0].schemaId, 300);
+ strictEqual(out[1].schemaId, 301);
+});
+
+// *** createFrameBuffer flush: parts.length > 1 -> concat needed *** //
+test(`${variant}: glueUnframeStream flush emits multi-part frame via concat (parts.length > 1)`, async () => {
+ const bigPayload = new Uint8Array(20);
+ for (let i = 0; i < 20; i++) bigPayload[i] = i + 1; // none start with 0x03
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([bigPayload]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ]),
+ );
+ const full = framed[0];
+ const c1 = full.slice(0, 18); // header exactly
+ const c2 = full.slice(18); // continuation
+ const out = await streamToArray(
+ pipejoin([createReadableStream([c1, c2]), glueUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaVersionId, schemaUuid);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(bigPayload));
+});
+
+// *** glueUnframeStream: frame exactly 17 bytes -> too short *** //
+test(`${variant}: glueUnframeStream rejects a frame that is exactly 17 bytes (byteLength < 18)`, async () => {
+ const short = new Uint8Array(17);
+ short[0] = 0x03;
+ try {
+ await pipeline([createReadableStream([short]), glueUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("magic byte"),
+ `Expected magic byte error, got: ${e.message}`,
+ );
+ }
+});
+
+// *** glueUnframeStream distinctIds tracking *** //
+test(`${variant}: glueUnframeStream.result() succeeds when exactly 1 distinct schemaVersionId seen`, async () => {
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0x42])]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ]),
+ );
+ const unframe = glueUnframeStream();
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ const result = unframe.result();
+ strictEqual(result.value.schemaVersionId, schemaUuid);
+});
+
+test(`${variant}: glueUnframeStream.result() throws when exactly 2 distinct schemaVersionIds seen`, async () => {
+ const idA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaa00";
+ const idB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbb00";
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: idA }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: idB }),
+ ]),
+ );
+ const unframe = glueUnframeStream();
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ try {
+ unframe.result();
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("distinct"),
+ `Expected distinct error, got: ${e.message}`,
+ );
+ ok(
+ e.message.includes("schemaVersionId"),
+ `Expected schemaVersionId in error, got: ${e.message}`,
+ );
+ }
+});
+
+// *** glueUnframeStream.result() resultKey *** //
+test(`${variant}: glueUnframeStream.result() uses custom resultKey`, async () => {
+ const unframe = glueUnframeStream({ resultKey: "myGlueKey" });
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ unframe,
+ ]),
+ );
+ strictEqual(unframe.result().key, "myGlueKey");
+});
+
+test(`${variant}: glueUnframeStream.result() defaults to "glueSchemaVersionId"`, async () => {
+ const unframe = glueUnframeStream();
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ unframe,
+ ]),
+ );
+ strictEqual(unframe.result().key, "glueSchemaVersionId");
+});
+
+// *** glueFrameStream.result() resultKey *** //
+test(`${variant}: glueFrameStream.result() uses custom resultKey`, async () => {
+ const stream = glueFrameStream({
+ schemaVersionId: schemaUuid,
+ resultKey: "myGlueFrameKey",
+ });
+ await streamToArray(pipejoin([createReadableStream([helloWorld]), stream]));
+ strictEqual(stream.result().key, "myGlueFrameKey");
+});
+
+test(`${variant}: glueFrameStream.result() defaults to "glueSchemaVersionId"`, async () => {
+ const stream = glueFrameStream({ schemaVersionId: schemaUuid });
+ await streamToArray(pipejoin([createReadableStream([helloWorld]), stream]));
+ strictEqual(stream.result().key, "glueSchemaVersionId");
+ strictEqual(stream.result().value.schemaVersionId, schemaUuid);
+});
+
+// *** glueUnframeStream: unsupported compression byte error message includes zero-padded hex *** //
+test(`${variant}: glueUnframeStream unsupported compression error message includes zero-padded hex byte`, async () => {
+ // byte 0x09 -> padStart(2,"0") gives "09", not "9"
+ const frame = new Uint8Array(18 + 1);
+ frame[0] = 0x03;
+ frame[1] = 0x09;
+ try {
+ await pipeline([createReadableStream([frame]), glueUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message.includes("0x09"),
+ `Expected '0x09' in error message, got: ${e.message}`,
+ );
+ }
+});
+
+// *** createFrameBuffer startsNewFrame: byteLength >= headerSize not > headerSize *** //
+test(`${variant}: confluentUnframeStream accepts a frame chunk of exactly 5 bytes as new-frame start`, async () => {
+ const emptyPayload = new Uint8Array(0);
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([emptyPayload]),
+ confluentFrameStream({ schemaId: 500 }),
+ ]),
+ );
+ strictEqual(framed[0].byteLength, 5);
+ const out = await streamToArray(
+ pipejoin([createReadableStream([framed[0]]), confluentUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 500);
+ strictEqual(out[0].payload.byteLength, 0);
+});
+
+test(`${variant}: glueUnframeStream accepts a frame chunk of exactly 18 bytes as new-frame start`, async () => {
+ const emptyPayload = new Uint8Array(0);
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([emptyPayload]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ]),
+ );
+ strictEqual(framed[0].byteLength, 18);
+ const out = await streamToArray(
+ pipejoin([createReadableStream([framed[0]]), glueUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaVersionId, schemaUuid);
+ strictEqual(out[0].payload.byteLength, 0);
+});
+
+// *** flush does nothing on empty stream *** //
+test(`${variant}: confluentUnframeStream flush does nothing when stream had no input`, async () => {
+ const out = await streamToArray(
+ pipejoin([createReadableStream([]), confluentUnframeStream()]),
+ );
+ strictEqual(out.length, 0);
+});
+
+test(`${variant}: glueUnframeStream flush does nothing when stream had no input`, async () => {
+ const out = await streamToArray(
+ pipejoin([createReadableStream([]), glueUnframeStream()]),
+ );
+ strictEqual(out.length, 0);
+});
+
+// *** Error messages must be non-empty strings (StringLiteral mutants) *** //
+test(`${variant}: confluentFrameStream schemaId validation error message is non-empty`, () => {
+ try {
+ confluentFrameStream({ schemaId: undefined });
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message !== "",
+ `Expected non-empty error message, got: "${e.message}"`,
+ );
+ ok(e.message.length > 10);
+ }
+});
+
+test(`${variant}: confluentUnframeStream missing magic byte error message is non-empty`, async () => {
+ const bogus = new Uint8Array([0x01, 0, 0, 0, 0, 0x68]);
+ try {
+ await pipeline([createReadableStream([bogus]), confluentUnframeStream()]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message !== "",
+ `Expected non-empty error message, got: "${e.message}"`,
+ );
+ ok(e.message.length > 10);
+ }
+});
+
+test(`${variant}: confluentUnframeStream.result() multiple-ids error message is non-empty`, async () => {
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 20 }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 21 }),
+ ]),
+ );
+ const unframe = confluentUnframeStream();
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ try {
+ unframe.result();
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message !== "",
+ `Expected non-empty error message, got: "${e.message}"`,
+ );
+ ok(e.message.length > 10);
+ }
+});
+
+test(`${variant}: glueUnframeStream.result() multiple-ids error message is non-empty`, async () => {
+ const idA = "11111111-1111-1111-1111-111111111111";
+ const idB = "22222222-2222-2222-2222-222222222222";
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: idA }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: idB }),
+ ]),
+ );
+ const unframe = glueUnframeStream();
+ await streamToArray(
+ pipejoin([createReadableStream([frameA[0], frameB[0]]), unframe]),
+ );
+ try {
+ unframe.result();
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(
+ e.message !== "",
+ `Expected non-empty error message, got: "${e.message}"`,
+ );
+ ok(e.message.length > 10);
+ }
+});
+
+// *** envelope contains both schemaId and payload fields (not empty object) *** //
+test(`${variant}: confluentUnframeStream envelope has schemaId and payload fields`, async () => {
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 999 }),
+ confluentUnframeStream(),
+ ]),
+ );
+ ok("schemaId" in out[0], "envelope should have schemaId");
+ ok("payload" in out[0], "envelope should have payload");
+ strictEqual(out[0].schemaId, 999);
+});
+
+// *** confluentFrameStream value object has schemaId field *** //
+test(`${variant}: confluentFrameStream.result() value object contains schemaId`, async () => {
+ const stream = confluentFrameStream({ schemaId: 42 });
+ await streamToArray(pipejoin([createReadableStream([helloWorld]), stream]));
+ ok("schemaId" in stream.result().value, "value should have schemaId");
+ strictEqual(stream.result().value.schemaId, 42);
+});
+
+// *** glueUnframeStream value object has schemaVersionId and compression *** //
+test(`${variant}: glueUnframeStream.result() value object contains schemaVersionId and compression`, async () => {
+ const unframe = glueUnframeStream();
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ unframe,
+ ]),
+ );
+ ok("schemaVersionId" in unframe.result().value);
+ ok("compression" in unframe.result().value);
+ strictEqual(unframe.result().value.schemaVersionId, schemaUuid);
+});
+
+// *** confluentUnframeStream.result() value object has schemaId field *** //
+test(`${variant}: confluentUnframeStream.result() value has schemaId field (not empty object)`, async () => {
+ const unframe = confluentUnframeStream();
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 88 }),
+ unframe,
+ ]),
+ );
+ ok("schemaId" in unframe.result().value);
+ strictEqual(unframe.result().value.schemaId, 88);
+});
+
+// *** bytesToUuid: padStart with "0" is needed for single-digit hex bytes *** //
+test(`${variant}: glueUnframeStream decodes UUID bytes with single-digit hex values (padStart needed)`, async () => {
+ // "00010203-0405-0607-0809-0a0b0c0d0e0f" has bytes 0x00..0x0f requiring padStart(2,"0")
+ const uuidWithSmallBytes = "00010203-0405-0607-0809-0a0b0c0d0e0f";
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: uuidWithSmallBytes }),
+ glueUnframeStream(),
+ ]),
+ );
+ strictEqual(out[0].schemaVersionId, uuidWithSmallBytes);
+});
+
+// *** bytesToUuid loop: i < 16 not <= 16 (exactly 16 bytes needed) *** //
+test(`${variant}: glueUnframeStream decodes all 16 UUID bytes correctly (loop bound i < 16)`, async () => {
+ // All-FF UUID - if loop ran 17 iterations (i <= 16) it would read out-of-bounds bytes.
+ const allFF = "ffffffff-ffff-ffff-ffff-ffffffffffff";
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0x42])]),
+ glueFrameStream({ schemaVersionId: allFF }),
+ glueUnframeStream(),
+ ]),
+ );
+ strictEqual(out[0].schemaVersionId, allFF);
+});
+
+// *** default export contains all 4 functions *** //
+test(`${variant}: default export contains all 4 stream factory functions`, async () => {
+ const mod = await import("@datastream/schema-registry");
+ const def = mod.default;
+ ok(
+ typeof def.confluentFrameStream === "function",
+ "confluentFrameStream missing",
+ );
+ ok(
+ typeof def.confluentUnframeStream === "function",
+ "confluentUnframeStream missing",
+ );
+ ok(typeof def.glueFrameStream === "function", "glueFrameStream missing");
+ ok(typeof def.glueUnframeStream === "function", "glueUnframeStream missing");
+});
+
+// *** createFrameBuffer: chunk < headerSize starting with magic is a continuation *** //
+test(`${variant}: confluentUnframeStream treats a chunk shorter than 5 bytes starting with 0x00 as continuation`, async () => {
+ // A 3-byte chunk starting with 0x00 is < headerSize(5), so it is NOT a new frame.
+ // Instead it is a continuation. Combined with the next bytes, it forms a valid frame.
+ // Build a valid frame and split it: first 3 bytes (header prefix), then the remaining 8 bytes.
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 600 }),
+ ]),
+ );
+ const full = framed[0];
+ // first 3 bytes: [0x00, 0x00, 0x00] — starts with magic but only 3 bytes (< 5)
+ const c1 = full.slice(0, 3);
+ const c2 = full.slice(3);
+ const out = await streamToArray(
+ pipejoin([createReadableStream([c1, c2]), confluentUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 600);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+});
+
+test(`${variant}: glueUnframeStream treats a chunk shorter than 18 bytes starting with 0x03 as continuation`, async () => {
+ // A 10-byte chunk starting with 0x03 is < headerSize(18), so it is a continuation.
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ ]),
+ );
+ const full = framed[0];
+ const c1 = full.slice(0, 10); // starts with 0x03 but < 18 bytes
+ const c2 = full.slice(10);
+ const out = await streamToArray(
+ pipejoin([createReadableStream([c1, c2]), glueUnframeStream()]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaVersionId, schemaUuid);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+});
+
+// *** collectStream: maxOutputSize null/undefined -> no limit applied *** //
+test(`${variant}: confluentUnframeStream initial value has schemaId as null before any frames`, async () => {
+ // Test that result().value.schemaId is null (not undefined) before frames processed.
+ // With the { schemaId: null } mutation -> {}, value.schemaId would be undefined.
+ const unframe = confluentUnframeStream();
+ // Process one frame so result() does not throw due to distinctIds check
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 50 }),
+ unframe,
+ ]),
+ );
+ strictEqual(unframe.result().value.schemaId, 50);
+});
+
+test(`${variant}: glueUnframeStream initial value fields present after processing`, async () => {
+ // Test that result().value has schemaVersionId and compression (not an empty object).
+ // With the { schemaVersionId: null, compression: null } -> {}, fields would be undefined.
+ const unframe = glueUnframeStream();
+ await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ glueFrameStream({ schemaVersionId: schemaUuid }),
+ unframe,
+ ]),
+ );
+ const v = unframe.result().value;
+ strictEqual(v.schemaVersionId, schemaUuid);
+ strictEqual(v.compression, "none");
+});
+
+// *** collectStream limitName fallback "maxDecompressedBytes" is non-empty string *** //
+test(`${variant}: glueUnframeStream limit error message contains non-empty limitName`, async () => {
+ // When glueUnframeStream calls inflate, limitName="maxDecompressedBytes" is passed.
+ // With the empty string mutant, the error would say "schema-registry: exceeded"
+ // instead of "schema-registry: maxDecompressedBytes exceeded".
+ const payload = new TextEncoder().encode("a".repeat(500));
+ try {
+ await pipeline([
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ glueUnframeStream({ maxDecompressedBytes: 10 }),
+ ]);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ // The error message must contain "maxDecompressedBytes" (not just "exceeded")
+ ok(
+ e.message.includes("maxDecompressedBytes"),
+ `Error should mention maxDecompressedBytes, got: ${e.message}`,
+ );
+ // Verify it's not an empty string for the limit name
+ const match = e.message.match(/schema-registry: (\S+) exceeded/);
+ ok(
+ match !== null,
+ `Error message format should match 'schema-registry: exceeded', got: ${e.message}`,
+ );
+ strictEqual(match[1], "maxDecompressedBytes");
+ }
+});
+
+// *** confluentUnframeStream initial value has schemaId as null (not undefined) *** //
+test(`${variant}: confluentUnframeStream.result() value.schemaId is null before any frames processed`, async () => {
+ // With { schemaId: null } -> {}, value.schemaId would be undefined, not null.
+ const unframe = confluentUnframeStream();
+ // distinctIds=0 so result() won't throw even before any frames.
+ const result = unframe.result();
+ strictEqual(result.value.schemaId, null);
+});
+
+// *** glueUnframeStream initial value fields are null (not undefined) *** //
+test(`${variant}: glueUnframeStream.result() value fields are null before any frames processed`, async () => {
+ // With { schemaVersionId: null, compression: null } -> {}, fields would be undefined.
+ const unframe = glueUnframeStream();
+ const result = unframe.result();
+ strictEqual(result.value.schemaVersionId, null);
+ strictEqual(result.value.compression, null);
+});
+
+// *** Multi-part push: payload bytes must be present in the completed first frame *** //
+test(`${variant}: confluentUnframeStream emits correct payload bytes from multi-part first frame`, async () => {
+ // Build a frame where the payload bytes differ from the header bytes.
+ // This ensures parts.length===1 ? parts[0] : concat(parts) actually works correctly
+ // when concat is needed (parts.length > 1 in the push path).
+ const distinctPayload = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]);
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([distinctPayload]),
+ confluentFrameStream({ schemaId: 700 }),
+ ]),
+ );
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array([0x01])]),
+ confluentFrameStream({ schemaId: 701 }),
+ ]),
+ );
+ // Split frameA into header + payload to force parts.length===2 in push.
+ const a1 = frameA[0].slice(0, 5); // header
+ const a2 = frameA[0].slice(5); // payload = [0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe]
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([a1, a2, frameB[0]]),
+ confluentUnframeStream(),
+ ]),
+ );
+ strictEqual(out.length, 2);
+ strictEqual(out[0].schemaId, 700);
+ // Must include ALL payload bytes (a2), not just the header (a1).
+ deepStrictEqual(Array.from(out[0].payload), Array.from(distinctPayload));
+ strictEqual(out[1].schemaId, 701);
+});
+
+// *** createFrameBuffer startsNewFrame requires the LENGTH check, not just magic ***
+// A short (< headerSize) continuation chunk that happens to start with the magic
+// byte must stay a continuation. Dropping the `byteLength >= headerSize` guard
+// (mutant: `true && bytes[0] === magic`) would mis-split it into its own frame.
+test(`${variant}: confluentUnframeStream keeps a <5-byte magic-leading continuation attached to the open frame`, async () => {
+ const framed = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 808 }),
+ ]),
+ );
+ // A 2-byte trailing continuation that begins with the magic byte 0x00.
+ const shortCont = new Uint8Array([0x00, 0x99]);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([framed[0], shortCont]),
+ confluentUnframeStream(),
+ ]),
+ );
+ // Correct: ONE frame whose payload is helloWorld + [0x00, 0x99].
+ strictEqual(out.length, 1);
+ strictEqual(out[0].schemaId, 808);
+ deepStrictEqual(Array.from(out[0].payload), [
+ ...Array.from(helloWorld),
+ 0x00,
+ 0x99,
+ ]);
+});
+
+// *** createFrameBuffer startsNewFrame uses >= headerSize, not > headerSize ***
+// A second frame that is EXACTLY headerSize bytes (empty payload) must be
+// recognised as a new frame. Mutant `> headerSize` would fold it into the
+// previous frame as a continuation.
+test(`${variant}: confluentUnframeStream treats an exactly-5-byte second frame as a new frame`, async () => {
+ const frameA = await streamToArray(
+ pipejoin([
+ createReadableStream([helloWorld]),
+ confluentFrameStream({ schemaId: 811 }),
+ ]),
+ );
+ // Second frame: empty payload => exactly 5 bytes, distinct schemaId.
+ const frameB = await streamToArray(
+ pipejoin([
+ createReadableStream([new Uint8Array(0)]),
+ confluentFrameStream({ schemaId: 812 }),
+ ]),
+ );
+ strictEqual(frameB[0].byteLength, 5);
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([frameA[0], frameB[0]]),
+ confluentUnframeStream(),
+ ]),
+ );
+ // Correct: TWO distinct envelopes. Mutant (`>`) would merge into one.
+ strictEqual(out.length, 2);
+ strictEqual(out[0].schemaId, 811);
+ deepStrictEqual(Array.from(out[0].payload), Array.from(helloWorld));
+ strictEqual(out[1].schemaId, 812);
+ strictEqual(out[1].payload.byteLength, 0);
+});
+
+// *** collectStream maxOutputSize guard: `!= null` must gate the size check ***
+// Passing maxDecompressedBytes: null explicitly disables the ceiling. The guard
+// `maxOutputSize != null && total > maxOutputSize` must short-circuit to false.
+// Mutant `true && total > maxOutputSize` becomes `total > null` => `total > 0`,
+// which would throw for any non-empty decompressed output.
+test(`${variant}: glueUnframeStream with maxDecompressedBytes: null imposes no ceiling`, async () => {
+ const payload = new TextEncoder().encode("a".repeat(2048));
+ const out = await streamToArray(
+ pipejoin([
+ createReadableStream([payload]),
+ glueFrameStream({ schemaVersionId: schemaUuid, compression: "zlib" }),
+ glueUnframeStream({ maxDecompressedBytes: null }),
+ ]),
+ );
+ strictEqual(out.length, 1);
+ strictEqual(out[0].compression, "zlib");
+ deepStrictEqual(Array.from(out[0].payload), Array.from(payload));
+});
diff --git a/packages/schema-registry/index.tst.ts b/packages/schema-registry/index.tst.ts
new file mode 100644
index 0000000..b7b2e6f
--- /dev/null
+++ b/packages/schema-registry/index.tst.ts
@@ -0,0 +1,73 @@
+///
+///
+import type {
+ ConfluentEnvelope,
+ GlueEnvelope,
+} from "@datastream/schema-registry";
+import {
+ confluentFrameStream,
+ confluentUnframeStream,
+ glueFrameStream,
+ glueUnframeStream,
+} from "@datastream/schema-registry";
+import { describe, expect, test } from "tstyche";
+
+describe("ConfluentEnvelope", () => {
+ test("has schemaId and payload", () => {
+ expect().type.toBeAssignableTo<{
+ schemaId: number;
+ payload: Uint8Array;
+ }>();
+ });
+});
+
+describe("GlueEnvelope", () => {
+ test("has schemaVersionId, compression, and payload", () => {
+ expect().type.toBeAssignableTo<{
+ schemaVersionId: string;
+ compression: "none" | "zlib";
+ payload: Uint8Array;
+ }>();
+ });
+});
+
+describe("confluentFrameStream", () => {
+ test("requires schemaId", () => {
+ expect(
+ confluentFrameStream({ schemaId: 1 }),
+ ).type.not.toBeAssignableTo();
+ });
+
+ test("exposes result", () => {
+ const stream = confluentFrameStream({ schemaId: 1 });
+ expect(stream.result).type.not.toBeAssignableTo();
+ });
+});
+
+describe("confluentUnframeStream", () => {
+ test("accepts no options", () => {
+ expect(confluentUnframeStream()).type.not.toBeAssignableTo();
+ });
+});
+
+describe("glueFrameStream", () => {
+ test("requires schemaVersionId", () => {
+ expect(
+ glueFrameStream({ schemaVersionId: "abc" }),
+ ).type.not.toBeAssignableTo();
+ });
+
+ test("accepts compression", () => {
+ expect(
+ glueFrameStream({ schemaVersionId: "abc", compression: "zlib" }),
+ ).type.not.toBeAssignableTo();
+ });
+});
+
+describe("glueUnframeStream", () => {
+ test("accepts maxDecompressedBytes", () => {
+ expect(
+ glueUnframeStream({ maxDecompressedBytes: 1024 }),
+ ).type.not.toBeAssignableTo();
+ });
+});
diff --git a/packages/schema-registry/package.json b/packages/schema-registry/package.json
new file mode 100644
index 0000000..cd2e0d9
--- /dev/null
+++ b/packages/schema-registry/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@datastream/schema-registry",
+ "version": "0.6.1",
+ "description": "Schema-Registry wire-format framing transform streams (Confluent and AWS Glue)",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "engineStrict": true,
+ "publishConfig": {
+ "access": "public"
+ },
+ "main": "./index.node.mjs",
+ "module": "./index.web.mjs",
+ "exports": {
+ ".": {
+ "node": {
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.node.mjs"
+ }
+ },
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.web.mjs"
+ }
+ },
+ "./webstream": {
+ "types": "./index.d.ts",
+ "default": "./index.web.mjs"
+ }
+ },
+ "types": "index.d.ts",
+ "files": [
+ "*.mjs",
+ "*.map",
+ "*.d.ts"
+ ],
+ "scripts": {
+ "test": "npm run test:unit",
+ "test:unit": "node --test",
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
+ },
+ "license": "MIT",
+ "keywords": [
+ "schema-registry",
+ "confluent",
+ "glue",
+ "avro",
+ "protobuf",
+ "Web Stream API",
+ "Node Stream API"
+ ],
+ "author": {
+ "name": "datastream contributors",
+ "url": "https://github.com/willfarrell/datastream/graphs/contributors"
+ },
+ "repository": {
+ "type": "git",
+ "url": "github:willfarrell/datastream",
+ "directory": "packages/schema-registry"
+ },
+ "bugs": {
+ "url": "https://github.com/willfarrell/datastream/issues"
+ },
+ "homepage": "https://datastream.js.org",
+ "dependencies": {
+ "@datastream/core": "0.6.1"
+ }
+}
diff --git a/packages/string/README.md b/packages/string/README.md
index 9f86585..e630ed7 100644
--- a/packages/string/README.md
+++ b/packages/string/README.md
@@ -1,6 +1,6 @@
<datastream> `string`
-
+
String manipulation streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/string/index.d.ts b/packages/string/index.d.ts
index 0be1c1f..2ec80a4 100644
--- a/packages/string/index.d.ts
+++ b/packages/string/index.d.ts
@@ -32,24 +32,30 @@ export function stringCountStream(
result: () => StreamResult;
};
-export function stringMinimumFirstChunkSize(
+export function stringMinimumFirstChunkSizeStream(
options?: {
chunkSize?: number;
},
streamOptions?: StreamOptions,
): DatastreamTransform;
+/** @deprecated Use stringMinimumFirstChunkSizeStream */
+export const stringMinimumFirstChunkSize: typeof stringMinimumFirstChunkSizeStream;
-export function stringMinimumChunkSize(
+export function stringMinimumChunkSizeStream(
options?: {
chunkSize?: number;
},
streamOptions?: StreamOptions,
): DatastreamTransform;
+/** @deprecated Use stringMinimumChunkSizeStream */
+export const stringMinimumChunkSize: typeof stringMinimumChunkSizeStream;
-export function stringSkipConsecutiveDuplicates(
+export function stringSkipConsecutiveDuplicatesStream(
options?: Record,
streamOptions?: StreamOptions,
): DatastreamTransform;
+/** @deprecated Use stringSkipConsecutiveDuplicatesStream */
+export const stringSkipConsecutiveDuplicates: typeof stringSkipConsecutiveDuplicatesStream;
export function stringReplaceStream(
options: {
@@ -72,7 +78,9 @@ declare const _default: {
readableStream: typeof stringReadableStream;
lengthStream: typeof stringLengthStream;
countStream: typeof stringCountStream;
- skipConsecutiveDuplicates: typeof stringSkipConsecutiveDuplicates;
+ minimumFirstChunkSize: typeof stringMinimumFirstChunkSizeStream;
+ minimumChunkSize: typeof stringMinimumChunkSizeStream;
+ skipConsecutiveDuplicates: typeof stringSkipConsecutiveDuplicatesStream;
replaceStream: typeof stringReplaceStream;
splitStream: typeof stringSplitStream;
};
diff --git a/packages/string/index.js b/packages/string/index.js
index 410d0d6..04f8cb2 100644
--- a/packages/string/index.js
+++ b/packages/string/index.js
@@ -23,16 +23,22 @@ export const stringCountStream = (
{ substr, resultKey } = {},
streamOptions = {},
) => {
+ if (typeof substr !== "string" || substr.length === 0) {
+ throw new Error("stringCountStream requires a non-empty substr");
+ }
let value = 0;
+ let carry = "";
const passThrough = (chunk) => {
+ const combined = carry + chunk;
let cursor = -1;
- while (cursor < chunk.length) {
- cursor = chunk.indexOf(substr, cursor + 1);
+ while (true) {
+ cursor = combined.indexOf(substr, cursor + 1);
if (cursor === -1) {
break;
}
value += 1;
}
+ carry = combined.slice(-(substr.length - 1));
};
const stream = createPassThroughStream(passThrough, streamOptions);
stream.result = () => ({ key: resultKey ?? "count", value });
@@ -54,7 +60,6 @@ export const stringMinimumFirstChunkSize = (
buffer += chunk;
if (buffer.length >= chunkSize) {
enqueue(buffer);
- buffer = "";
done = true;
}
};
@@ -141,6 +146,9 @@ export const stringSplitStream = (options, streamOptions = {}) => {
separator,
maxBufferSize = 16_777_216, // 16MB
} = options;
+ if (typeof separator !== "string" || separator.length === 0) {
+ throw new Error("stringSplitStream requires a non-empty separator");
+ }
let previousChunk = "";
const transform = (chunk, enqueue) => {
chunk = previousChunk + chunk;
diff --git a/packages/string/index.test.js b/packages/string/index.test.js
index 748d904..0693d2b 100644
--- a/packages/string/index.test.js
+++ b/packages/string/index.test.js
@@ -305,6 +305,35 @@ test(`${variant}: stringSplitStream should throw when buffer exceeds maxBufferSi
}
});
+// *** stringSplitStream empty separator guard *** //
+test(`${variant}: stringSplitStream should throw on empty separator at construction`, (_t) => {
+ let threw = false;
+ try {
+ stringSplitStream({ separator: "" });
+ } catch (e) {
+ threw = true;
+ ok(
+ e.message.includes("non-empty separator"),
+ `expected non-empty separator in error, got: ${e.message}`,
+ );
+ }
+ ok(threw, "stringSplitStream({ separator: '' }) should throw");
+});
+
+// *** stringCountStream cross-chunk boundary *** //
+test(`${variant}: stringCountStream should count occurrences spanning chunk boundaries`, async (_t) => {
+ const input = ["hel", "lo"];
+ const streams = [
+ createReadableStream(input),
+ stringCountStream({ substr: "hello" }),
+ ];
+
+ await pipeline(streams);
+ const { value } = streams[1].result();
+
+ strictEqual(value, 1);
+});
+
// *** default export *** //
test(`${variant}: default export should include all stream functions`, (_t) => {
deepStrictEqual(Object.keys(stringDefault).sort(), [
@@ -316,3 +345,307 @@ test(`${variant}: default export should include all stream functions`, (_t) => {
"splitStream",
]);
});
+
+// *** stringCountStream guard: typeof check *** //
+test(`${variant}: stringCountStream should throw when substr is not a string (number)`, (_t) => {
+ let threw = false;
+ try {
+ stringCountStream({ substr: 42 });
+ } catch (e) {
+ threw = true;
+ strictEqual(e.message, "stringCountStream requires a non-empty substr");
+ }
+ ok(threw, "stringCountStream({ substr: 42 }) should throw");
+});
+
+test(`${variant}: stringCountStream should throw when substr is not a string (undefined)`, (_t) => {
+ let threw = false;
+ try {
+ stringCountStream({});
+ } catch (e) {
+ threw = true;
+ strictEqual(e.message, "stringCountStream requires a non-empty substr");
+ }
+ ok(threw, "stringCountStream({}) should throw");
+});
+
+test(`${variant}: stringCountStream should throw on empty substr`, (_t) => {
+ let threw = false;
+ try {
+ stringCountStream({ substr: "" });
+ } catch (e) {
+ threw = true;
+ strictEqual(e.message, "stringCountStream requires a non-empty substr");
+ }
+ ok(threw, "stringCountStream({ substr: '' }) should throw");
+});
+
+// *** stringCountStream: cursor boundary (< vs <=) *** //
+test(`${variant}: stringCountStream should count match at the very end of combined string`, async (_t) => {
+ // substr "ab" with combined exactly ending in "ab" — exercises cursor at length boundary
+ const input = ["xab"];
+ const streams = [
+ createReadableStream(input),
+ stringCountStream({ substr: "ab" }),
+ ];
+ await pipeline(streams);
+ const { value } = streams[1].result();
+ strictEqual(value, 1);
+});
+
+// *** stringCountStream: carry arithmetic (substr.length - 1 vs + 1) *** //
+test(`${variant}: stringCountStream should NOT double-count across chunk boundary when carry is exact`, async (_t) => {
+ // substr = "ab", chunk1 = "a", chunk2 = "b" => carry="a", combined="ab" => 1 match
+ // if carry used -(length+1) it would incorrectly carry "xa" or extra chars, changing count
+ const input = ["a", "b", "ab"];
+ const streams = [
+ createReadableStream(input),
+ stringCountStream({ substr: "ab" }),
+ ];
+ await pipeline(streams);
+ const { value } = streams[1].result();
+ strictEqual(value, 2);
+});
+
+test(`${variant}: stringCountStream carry should not include extra characters that cause false positives`, async (_t) => {
+ // substr = "ab" (length 2), carry should be 1 char (length-1)
+ // chunk1="xxa", chunk2="b" => combined="xxab", carry after chunk1 = "a" (1 char)
+ // if carry were 2 chars ("xa"), combined in chunk2 = "xab", still finds "ab" once
+ // but with "aab" boundary: chunk1="aab", chunk2="c" => should count 1 not 2
+ const input = ["aab", "c"];
+ const streams = [
+ createReadableStream(input),
+ stringCountStream({ substr: "ab" }),
+ ];
+ await pipeline(streams);
+ const { value } = streams[1].result();
+ strictEqual(value, 1);
+});
+
+test(`${variant}: stringCountStream carry boundary with 3-char substr`, async (_t) => {
+ // substr = "abc" (length 3), carry should be 2 chars (length-1=2)
+ // chunk1="xab", chunk2="cd" => carry="ab" (2 chars), combined="abcd", finds "abc" once total
+ // if carry were 3 chars "xab", combined="xabcd", still finds "abc" once - same
+ // Better: chunk1="abc" chunk2="abc" => 2 matches; carry from chunk1 = "bc" (2 chars)
+ // combined in chunk2 = "bcabc" => finds "abc" at pos 2 => total = 2
+ const input = ["abc", "abc"];
+ const streams = [
+ createReadableStream(input),
+ stringCountStream({ substr: "abc" }),
+ ];
+ await pipeline(streams);
+ const { value } = streams[1].result();
+ strictEqual(value, 2);
+});
+
+// *** stringMinimumFirstChunkSize: buffer reset after emit *** //
+test(`${variant}: stringMinimumFirstChunkSize should output exact chunk size when met and not carry leftovers`, async (_t) => {
+ // chunkSize=4, input ["ab","cd","ef"]
+ // After emitting "abcd", buffer must be "" so next chunk "ef" is passed through as-is
+ // If buffer="" mutant used "Stryker was here!", subsequent chunk would be wrong
+ const input = ["ab", "cd", "ef"];
+ const streams = [
+ createReadableStream(input),
+ stringMinimumFirstChunkSize({ chunkSize: 4 }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["abcd", "ef"]);
+});
+
+test(`${variant}: stringMinimumFirstChunkSize should not emit in flush when done=true and buffer empty`, async (_t) => {
+ // When chunkSize is met exactly, done=true and buffer="" => flush should emit nothing
+ const input = ["abcd"];
+ const streams = [
+ createReadableStream(input),
+ stringMinimumFirstChunkSize({ chunkSize: 4 }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ // "abcd" meets chunkSize=4 exactly, emitted in transform; flush emits nothing (buffer empty, done=true)
+ deepStrictEqual(output, ["abcd"]);
+});
+
+test(`${variant}: stringMinimumFirstChunkSize should not flush empty buffer when done=false`, async (_t) => {
+ // Input is empty => done=false, buffer="" => flush should NOT emit empty string
+ const input = [""];
+ const streams = [
+ createReadableStream(input),
+ stringMinimumFirstChunkSize({ chunkSize: 4 }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ // buffer is "" — flush condition `buffer.length > 0` must prevent enqueue
+ deepStrictEqual(output, []);
+});
+
+// *** stringReplaceStream: RegExp guard *** //
+test(`${variant}: stringReplaceStream should throw on RegExp without g or y flag`, (_t) => {
+ let threw = false;
+ try {
+ stringReplaceStream({ pattern: /hello/, replacement: "hi" });
+ } catch (e) {
+ threw = true;
+ strictEqual(
+ e.message,
+ "RegExp pattern must include the global (g) or sticky (y) flag",
+ );
+ }
+ ok(threw, "stringReplaceStream with /hello/ (no flags) should throw");
+});
+
+test(`${variant}: stringReplaceStream should NOT throw on RegExp with g flag`, async (_t) => {
+ const input = ["hello"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({ pattern: /hello/g, replacement: "hi" }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["", "hi"]);
+});
+
+test(`${variant}: stringReplaceStream should NOT throw on RegExp with y flag`, async (_t) => {
+ const input = ["hello"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({ pattern: /hello/y, replacement: "hi" }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["", "hi"]);
+});
+
+test(`${variant}: stringReplaceStream should NOT throw on RegExp with both g and y flags`, async (_t) => {
+ const input = ["hello"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({ pattern: /hello/gy, replacement: "hi" }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["", "hi"]);
+});
+
+// *** stringReplaceStream: useReplaceAll (string vs RegExp path) *** //
+test(`${variant}: stringReplaceStream should replace ALL occurrences with string pattern (replaceAll path)`, async (_t) => {
+ // String pattern uses replaceAll, regex uses replace
+ // With string "a", input "aaa" => all 3 replaced
+ const input = ["aaa"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({ pattern: "a", replacement: "b" }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["", "bbb"]);
+});
+
+test(`${variant}: stringReplaceStream with regex /a/g should replace all occurrences (replace path with global)`, async (_t) => {
+ const input = ["aaa"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({ pattern: /a/g, replacement: "b" }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["", "bbb"]);
+});
+
+// *** stringReplaceStream: maxBufferSize exact boundary (> vs >=) *** //
+test(`${variant}: stringReplaceStream should NOT throw when buffer equals maxBufferSize exactly`, async (_t) => {
+ // previousChunk.length === maxBufferSize should NOT throw (only > maxBufferSize should)
+ // Use pattern that never matches so buffer grows; first chunk "aaaa" (4 chars) with maxBufferSize=4
+ // After first chunk: combined="aaaa", enqueue substring(0,0)="", previousChunk="aaaa" (4 chars)
+ // 4 > 4 is false => should NOT throw
+ const input = ["aaaa"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({
+ pattern: "zzz",
+ replacement: "yyy",
+ maxBufferSize: 4,
+ }),
+ ];
+ // Should not throw for 4-char buffer with maxBufferSize=4
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["", "aaaa"]);
+});
+
+test(`${variant}: stringReplaceStream should throw when buffer exceeds maxBufferSize by 1`, async (_t) => {
+ // previousChunk.length = 5 > maxBufferSize = 4 => throws
+ const input = ["aaaaa"];
+ const streams = [
+ createReadableStream(input),
+ stringReplaceStream({
+ pattern: "zzz",
+ replacement: "yyy",
+ maxBufferSize: 4,
+ }),
+ ];
+ try {
+ await pipeline(streams);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("maxBufferSize"));
+ }
+});
+
+// *** stringSplitStream: typeof separator guard *** //
+test(`${variant}: stringSplitStream should throw when separator is not a string (number)`, (_t) => {
+ let threw = false;
+ try {
+ stringSplitStream({ separator: 42 });
+ } catch (e) {
+ threw = true;
+ ok(
+ e.message.includes("non-empty separator"),
+ `expected non-empty separator error, got: ${e.message}`,
+ );
+ }
+ ok(threw, "stringSplitStream({ separator: 42 }) should throw");
+});
+
+test(`${variant}: stringSplitStream should throw when separator is undefined`, (_t) => {
+ let threw = false;
+ try {
+ stringSplitStream({});
+ } catch (e) {
+ threw = true;
+ ok(
+ e.message.includes("non-empty separator"),
+ `expected non-empty separator error, got: ${e.message}`,
+ );
+ }
+ ok(threw, "stringSplitStream({}) should throw");
+});
+
+// *** stringSplitStream: maxBufferSize exact boundary (> vs >=) *** //
+test(`${variant}: stringSplitStream should NOT throw when buffer equals maxBufferSize exactly`, async (_t) => {
+ // separator "zzz" won't be found; chunk "aaaa" (4 chars), maxBufferSize=4
+ // previousChunk.length=4, 4 > 4 is false => should NOT throw
+ const input = ["aaaa"];
+ const streams = [
+ createReadableStream(input),
+ stringSplitStream({ separator: "zzz", maxBufferSize: 4 }),
+ ];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ deepStrictEqual(output, ["aaaa"]);
+});
+
+test(`${variant}: stringSplitStream should throw when buffer exceeds maxBufferSize by 1`, async (_t) => {
+ // chunk "aaaaa" (5 chars), maxBufferSize=4; 5 > 4 => throws
+ const input = ["aaaaa"];
+ const streams = [
+ createReadableStream(input),
+ stringSplitStream({ separator: "zzz", maxBufferSize: 4 }),
+ ];
+ try {
+ await pipeline(streams);
+ throw new Error("Should have thrown");
+ } catch (e) {
+ ok(e.message.includes("maxBufferSize"));
+ }
+});
diff --git a/packages/string/package.json b/packages/string/package.json
index e8fc19b..bfc0929 100644
--- a/packages/string/package.json
+++ b/packages/string/package.json
@@ -1,6 +1,6 @@
{
"name": "@datastream/string",
- "version": "0.5.0",
+ "version": "0.6.1",
"description": "String transform streams for splitting, replacing, counting, and deduplication",
"type": "module",
"engines": {
@@ -39,7 +39,8 @@
"scripts": {
"test": "npm run test:unit",
"test:unit": "node --test",
- "test:benchmark": "node __benchmarks__/index.js"
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
},
"license": "MIT",
"keywords": [
@@ -60,6 +61,6 @@
},
"homepage": "https://datastream.js.org",
"dependencies": {
- "@datastream/core": "0.5.0"
+ "@datastream/core": "0.6.1"
}
}
diff --git a/packages/validate/README.md b/packages/validate/README.md
index a82d4f1..5211f4e 100644
--- a/packages/validate/README.md
+++ b/packages/validate/README.md
@@ -1,6 +1,6 @@
<datastream> `validate`
-
+
JSON schema validation streams.
@@ -18,10 +18,10 @@
-
+
-
+
You can read the documentation at: https://datastream.js.org
diff --git a/packages/validate/index.js b/packages/validate/index.js
index 56b65fb..5588449 100644
--- a/packages/validate/index.js
+++ b/packages/validate/index.js
@@ -26,9 +26,16 @@ export const validateStream = (
allowCoerceTypes,
resultKey,
maxErrorRows = Infinity,
- },
+ maxErrorKeys = 1000,
+ } = {},
streamOptions = {},
) => {
+ if (!schema) {
+ throw new Error(
+ "validateStream requires a schema (JSON Schema object or compiled AJV function)",
+ );
+ }
+
idxStart ??= 0;
if (typeof schema !== "function") {
@@ -36,18 +43,48 @@ export const validateStream = (
}
const value = {}; // aka errors
+ let valueCount = 0; // track distinct id count without Object.keys() per error
let idx = idxStart - 1;
const transform = (chunk, enqueue) => {
idx += 1;
- const data =
- allowCoerceTypes === false ? structuredClone(chunk) : undefined;
- const chunkValid = schema(chunk);
+
+ let emitChunk;
+ let validateTarget;
+
+ if (allowCoerceTypes === false) {
+ // Validate on a clone so AJV coercion does not mutate the original.
+ // Emit the original (uncoerced) chunk.
+ try {
+ validateTarget = structuredClone(chunk);
+ } catch {
+ // Non-cloneable: fall back to validating the original (best-effort)
+ validateTarget = chunk;
+ }
+ emitChunk = chunk;
+ } else {
+ // Clone before validation so AJV coercion does not mutate caller's object.
+ // Emit the coerced clone.
+ try {
+ validateTarget = structuredClone(chunk);
+ } catch {
+ // Non-cloneable: validate the original (caller's object may be mutated)
+ validateTarget = chunk;
+ }
+ emitChunk = validateTarget;
+ }
+
+ const chunkValid = schema(validateTarget);
if (!chunkValid) {
for (const error of schema.errors) {
const { id, keys, message } = processError(error);
if (!value[id]) {
+ // Stop creating new entries once maxErrorKeys cap is reached
+ if (valueCount >= maxErrorKeys) {
+ continue;
+ }
value[id] = { id, keys, message, idx: [] };
+ valueCount += 1;
}
if (value[id].idx.length < maxErrorRows) {
value[id].idx.push(idx);
@@ -55,7 +92,7 @@ export const validateStream = (
}
}
if (chunkValid || onErrorEnqueue) {
- enqueue(data ?? chunk);
+ enqueue(emitChunk);
}
};
const stream = createTransformStream(transform, streamOptions);
@@ -76,7 +113,8 @@ const processError = (error) => {
});
keys = [...new Set(keys.sort())];
} else {
- keys.push(makeKeys(error));
+ const key = makeKeys(error);
+ if (key) keys.push(key);
}
if (!error.instancePath && keys.length) {
id += `/${keys.join("|")}`;
diff --git a/packages/validate/index.test.js b/packages/validate/index.test.js
index 5f3eefc..7d399b7 100644
--- a/packages/validate/index.test.js
+++ b/packages/validate/index.test.js
@@ -368,8 +368,8 @@ test(`${variant}: validateStream should handle root-level type error`, async (_t
ok(Object.keys(result.validate).length > 0);
const errorKey = Object.keys(result.validate)[0];
- // makeKeys returns "" for root-level errors (empty instancePath)
- deepStrictEqual(result.validate[errorKey].keys, [""]);
+ // root-level errors produce an empty makeKeys result; empty keys are filtered out
+ deepStrictEqual(result.validate[errorKey].keys, []);
});
test(`${variant}: validateStream should handle errorMessage with root-level error`, async (_t) => {
@@ -446,7 +446,330 @@ test(`${variant}: validateStream should cap error indices with maxErrorRows`, as
ok(result.validate[errorKey].idx.length <= 10);
});
+// *** maxErrorKeys *** //
+test(`${variant}: validateStream should cap distinct error ids with maxErrorKeys`, async (_t) => {
+ // Feed 500 rows, each with a unique extra property name → would produce 500 distinct ids
+ const schema = {
+ type: "object",
+ additionalProperties: false,
+ properties: { a: { type: "number" } },
+ };
+ const input = Array.from({ length: 500 }, (_, i) => ({
+ a: 1,
+ [`extra_${i}`]: i,
+ }));
+ const streams = [
+ createReadableStream(input),
+ validateStream({ schema, maxErrorKeys: 50 }),
+ ];
+ const result = await pipeline(streams);
+
+ // Should never exceed maxErrorKeys distinct entries
+ ok(Object.keys(result.validate).length <= 50);
+});
+
+test(`${variant}: validateStream should default maxErrorKeys to 1000`, async (_t) => {
+ // 1500 rows each with a unique extra property → default cap of 1000 should kick in
+ const schema = {
+ type: "object",
+ additionalProperties: false,
+ properties: { a: { type: "number" } },
+ };
+ const input = Array.from({ length: 1500 }, (_, i) => ({
+ a: 1,
+ [`extra_${i}`]: i,
+ }));
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+
+ ok(Object.keys(result.validate).length <= 1000);
+});
+
+// *** allowCoerceTypes:false with non-cloneable chunks *** //
+test(`${variant}: validateStream should not throw when allowCoerceTypes:false and chunk contains non-cloneable value`, async (_t) => {
+ const schema = {
+ type: "object",
+ properties: { a: { type: "number" } },
+ required: ["a"],
+ };
+ // Chunk with a function (not structuredClone-able)
+ const input = [{ a: 1, fn: () => {} }];
+
+ const streams = [
+ createReadableStream(input),
+ validateStream({ schema, allowCoerceTypes: false }),
+ ];
+ // Should not throw — should fall back to original chunk
+ const result = await pipeline(streams);
+ ok(result, "pipeline should complete without error");
+});
+
+// *** root-level errors: no trailing-slash id, no empty-string key *** //
+test(`${variant}: validateStream root-level error should not have trailing-slash id or empty-string key`, async (_t) => {
+ const schema = { type: "object" };
+ const input = ["not-an-object"];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+
+ ok(Object.keys(result.validate).length > 0);
+ for (const [id, entry] of Object.entries(result.validate)) {
+ // id must not end with '/'
+ strictEqual(id.endsWith("/"), false, `id '${id}' must not end with '/'`);
+ // keys must not include ''
+ strictEqual(
+ entry.keys.includes(""),
+ false,
+ `keys must not include empty string`,
+ );
+ }
+});
+
+// *** default path should not mutate caller's input *** //
+test(`${variant}: validateStream should emit a clone so caller input is not mutated`, async (_t) => {
+ // coerceTypes:true would mutate {a:'1'} → {a:1} on the original object
+ const original = { a: "1" };
+ const input = [original];
+ const schema = {
+ type: "object",
+ properties: { a: { type: "number" } },
+ required: ["a"],
+ };
+
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const output = await streamToArray(pipejoin(streams));
+
+ // The emitted chunk should be coerced
+ strictEqual(output[0].a, 1);
+ // But the original reference must NOT be mutated
+ strictEqual(
+ original.a,
+ "1",
+ "caller's original object must not be mutated in place",
+ );
+});
+
+// *** no-schema branded error *** //
+test(`${variant}: validateStream should throw a branded error when schema is missing`, (_t) => {
+ try {
+ validateStream({});
+ ok(false, "should have thrown");
+ } catch (err) {
+ ok(
+ err.message.includes("validateStream") && err.message.includes("schema"),
+ `expected branded error mentioning validateStream and schema, got: ${err.message}`,
+ );
+ }
+});
+
// *** default export *** //
test(`${variant}: default export should be validateStream`, (_t) => {
strictEqual(validateDefault, validateStream);
});
+
+// *** transpileSchema strict:true mode *** //
+test(`${variant}: transpileSchema should use strict:true by default and reject schemas with unknown keywords`, (_t) => {
+ // In strict mode, AJV throws on unknown keywords in schemas
+ let threw = false;
+ try {
+ transpileSchema({ type: "object", unknownKeyword: true });
+ } catch {
+ threw = true;
+ }
+ // strict:false would silently accept unknown keywords; strict:true throws
+ strictEqual(
+ threw,
+ true,
+ "transpileSchema should throw on unknown keywords in strict mode",
+ );
+});
+
+// *** transpileSchema useDefaults:"empty" *** //
+test(`${variant}: transpileSchema should apply default values for empty strings (useDefaults:"empty")`, async (_t) => {
+ // useDefaults:"empty" fills defaults even when value is an empty string
+ const schema = {
+ type: "object",
+ properties: {
+ name: { type: "string", default: "fallback" },
+ },
+ };
+ const input = [{ name: "" }];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const output = await streamToArray(pipejoin(streams));
+ // useDefaults:"empty" should replace empty string with the default
+ deepStrictEqual(output[0].name, "fallback");
+});
+
+// *** catch block fallback: non-cloneable in allowCoerceTypes:false path *** //
+test(`${variant}: validateStream allowCoerceTypes:false with non-cloneable chunk emits original chunk`, async (_t) => {
+ const schema = {
+ type: "object",
+ properties: { a: { type: "string" } },
+ };
+ const original = { a: "hello", fn: () => {} };
+ const input = [original];
+ const streams = [
+ createReadableStream(input),
+ validateStream({ schema, allowCoerceTypes: false }),
+ ];
+ const output = await streamToArray(pipejoin(streams));
+ // Should emit the original object reference (the chunk itself, not a clone)
+ strictEqual(
+ output[0],
+ original,
+ "non-cloneable chunk should emit original reference when allowCoerceTypes:false",
+ );
+ strictEqual(output[0].a, "hello");
+});
+
+// *** catch block fallback: non-cloneable in default (coerce) path *** //
+test(`${variant}: validateStream default coerce path with non-cloneable chunk still emits chunk`, async (_t) => {
+ const schema = {
+ type: "object",
+ properties: { a: { type: "string" } },
+ };
+ const original = { a: "hello", fn: () => {} };
+ const input = [original];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const output = await streamToArray(pipejoin(streams));
+ // Should emit something (the original chunk when clone fails)
+ strictEqual(output[0].a, "hello");
+});
+
+// *** if (!value[id]) guard: same error should accumulate idx, not reset *** //
+test(`${variant}: validateStream should accumulate idx for repeated same-key errors`, async (_t) => {
+ // Multiple rows with same type error → same id → idx should accumulate
+ const input = [{ a: "bad" }, { a: "bad" }, { a: "bad" }];
+ const schema = {
+ type: "object",
+ properties: { a: { type: "number" } },
+ };
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+ const errorKey = Object.keys(result.validate)[0];
+ // All three rows should appear in idx
+ deepStrictEqual(result.validate[errorKey].idx, [0, 1, 2]);
+});
+
+// *** if (chunkValid || onErrorEnqueue): invalid items NOT enqueued by default *** //
+test(`${variant}: validateStream should not enqueue invalid items when onErrorEnqueue is not set`, async (_t) => {
+ const input = [
+ { a: "valid_number_string" },
+ { a: "definitely-not-a-number" },
+ ];
+ const schema = {
+ type: "object",
+ properties: { a: { type: "number" } },
+ };
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const stream = pipejoin(streams);
+ const output = await streamToArray(stream);
+ // Only valid (coerced) items should be emitted — "definitely-not-a-number" can't coerce
+ // "valid_number_string" also can't coerce; both invalid → output empty
+ deepStrictEqual(output.length, 0);
+});
+
+// *** errorMessage keyword: branch must populate keys from sub-errors *** //
+test(`${variant}: validateStream errorMessage should collect keys from sub-errors`, async (_t) => {
+ const schema = {
+ type: "object",
+ properties: {
+ age: { type: "number" },
+ },
+ required: ["age"],
+ errorMessage: {
+ properties: {
+ age: "Age must be a number",
+ },
+ },
+ };
+ const input = [{ age: "not-a-number" }];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+
+ ok(Object.keys(result.validate).length > 0);
+ const errorEntry = result.validate["#/errorMessage"];
+ ok(errorEntry, "errorMessage error entry should exist");
+ // Keys should be populated from the sub-error (age)
+ ok(
+ errorEntry.keys.includes("age"),
+ `keys should include 'age', got: ${JSON.stringify(errorEntry.keys)}`,
+ );
+ strictEqual(errorEntry.message, "Age must be a number");
+});
+
+// *** errorMessage keys sorted and deduped *** //
+test(`${variant}: validateStream errorMessage should sort keys alphabetically (z_field before a_field in required → sorted as a_field|z_field)`, async (_t) => {
+ // Required array has z_field FIRST so AJV returns sub-errors as [z_field, a_field].
+ // The sort() must reorder them to [a_field, z_field] for the id to be correct.
+ const schema = {
+ type: "object",
+ properties: {
+ z_field: { type: "string" },
+ a_field: { type: "string" },
+ },
+ required: ["z_field", "a_field"],
+ errorMessage: {
+ required: "Both fields are required",
+ },
+ };
+ const input = [{}];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+
+ ok(Object.keys(result.validate).length > 0);
+ // Without sort: id would be "#/errorMessage/z_field|a_field"
+ // With sort: id must be "#/errorMessage/a_field|z_field"
+ const errorEntry = result.validate["#/errorMessage/a_field|z_field"];
+ ok(
+ errorEntry,
+ "error entry id '#/errorMessage/a_field|z_field' must exist (keys sorted)",
+ );
+ deepStrictEqual(errorEntry.keys, ["a_field", "z_field"]);
+ strictEqual(errorEntry.message, "Both fields are required");
+});
+
+// *** errorMessage: falsy makeKeys values should NOT be pushed into keys *** //
+test(`${variant}: validateStream errorMessage sub-errors with empty instancePath produce no empty string keys`, async (_t) => {
+ const schema = {
+ type: "object",
+ errorMessage: "Must be an object",
+ };
+ const input = ["not-an-object"];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+
+ ok(Object.keys(result.validate).length > 0);
+ for (const entry of Object.values(result.validate)) {
+ strictEqual(
+ entry.keys.includes(""),
+ false,
+ "keys must not include empty string from errorMessage sub-errors",
+ );
+ }
+});
+
+// *** id suffix: multiple keys joined with "|" not "" *** //
+test(`${variant}: validateStream error id should join multiple keys with pipe separator`, async (_t) => {
+ // Use required errorMessage with two required fields. Both missing → two sub-errors
+ // → keys = ["a_field", "b_field"] → id = "#/errorMessage/a_field|b_field"
+ const schema = {
+ type: "object",
+ properties: {
+ b_field: { type: "string" },
+ a_field: { type: "string" },
+ },
+ required: ["a_field", "b_field"],
+ errorMessage: {
+ required: "Fields a_field and b_field are required",
+ },
+ };
+ const input = [{}];
+ const streams = [createReadableStream(input), validateStream({ schema })];
+ const result = await pipeline(streams);
+
+ // The entry id MUST be "#/errorMessage/a_field|b_field" — pipe not empty-string join
+ const errorEntry = result.validate["#/errorMessage/a_field|b_field"];
+ ok(errorEntry, "error entry id '#/errorMessage/a_field|b_field' must exist");
+ deepStrictEqual(errorEntry.keys, ["a_field", "b_field"]);
+ strictEqual(errorEntry.message, "Fields a_field and b_field are required");
+});
diff --git a/packages/validate/package.json b/packages/validate/package.json
index b91380a..a6dd099 100644
--- a/packages/validate/package.json
+++ b/packages/validate/package.json
@@ -1,6 +1,6 @@
{
"name": "@datastream/validate",
- "version": "0.5.0",
+ "version": "0.6.1",
"description": "JSON Schema validation transform streams using Ajv",
"type": "module",
"engines": {
@@ -39,7 +39,8 @@
"scripts": {
"test": "npm run test:unit",
"test:unit": "node --test",
- "test:benchmark": "node __benchmarks__/index.js"
+ "test:perf": "node --test index.perf.js",
+ "test:mutation": "cd ../.. && MUTATE_PACKAGE=\"$(basename \"$OLDPWD\")\" stryker run"
},
"license": "MIT",
"keywords": [
@@ -60,7 +61,7 @@
},
"homepage": "https://datastream.js.org",
"dependencies": {
- "@datastream/core": "0.5.0",
+ "@datastream/core": "0.6.1",
"ajv-cmd": "0.13.3"
}
}
diff --git a/stryker.config.mjs b/stryker.config.mjs
new file mode 100644
index 0000000..d907e1a
--- /dev/null
+++ b/stryker.config.mjs
@@ -0,0 +1,21 @@
+// Copyright 2026 will Farrell, and datastream contributors.
+// SPDX-License-Identifier: MIT
+const pkg = process.env.MUTATE_PACKAGE;
+const base = pkg ? `packages/${pkg}` : "packages";
+
+/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */
+export default {
+ packageManager: "npm",
+ testRunner: "command",
+ commandRunner: {
+ command: `node --test --conditions=node --test-force-exit ./${base}/**/*.test.js`,
+ },
+ coverageAnalysis: "off",
+ mutate: [`${base}/**/*.node.mjs`, "!**/*.map", "!**/node_modules/**"],
+ plugins: ["@stryker-mutator/*"],
+ reporters: ["progress", "clear-text"],
+ thresholds: { high: 100, low: 100, break: 100 },
+ tempDirName: pkg
+ ? `/tmp/stryker/@datastream/${pkg}`
+ : "/tmp/stryker/@datastream",
+};
diff --git a/stryker.node-test.config.json b/stryker.node-test.config.json
new file mode 100644
index 0000000..75a973d
--- /dev/null
+++ b/stryker.node-test.config.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
+ "packageManager": "npm",
+ "testRunner": "node-test",
+ "nodeTest": {
+ "testFiles": ["packages/encrypt/**/*.test.js"],
+ "nodeArgs": ["--conditions=node"],
+ "concurrency": false
+ },
+ "coverageAnalysis": "perTest",
+ "mutate": [
+ "packages/encrypt/**/*.node.mjs",
+ "!**/*.map",
+ "!**/node_modules/**"
+ ],
+ "plugins": ["@stryker-mutator/node-test-runner"],
+ "reporters": ["progress", "clear-text"],
+ "thresholds": { "high": 100, "low": 100, "break": 0 },
+ "tempDirName": "/tmp/stryker/@datastream/node-test"
+}
diff --git a/websites/datastream.js.org/.tardisec.json b/websites/datastream.js.org/.tardisec.json
new file mode 100644
index 0000000..7ff8579
--- /dev/null
+++ b/websites/datastream.js.org/.tardisec.json
@@ -0,0 +1,35 @@
+{
+ "dns": {
+ "txt": ["tardisec-verification=9a3890fa-a783-51c2-b20f-0dbe457d526f"]
+ },
+ "http": {
+ "headers": {
+ "Connection-Allowlist": "(\"*\");report-to=default",
+ "Connection-Allowlist-Report-Only": "();report-to=default",
+ "Content-Security-Policy": "base-uri 'none';connect-src 'self';default-src 'report-sample' 'report-sha256';form-action 'self';frame-ancestors 'none';img-src 'self';manifest-src 'self';report-to default;report-uri https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org;script-src 'self' 'report-sample' 'report-sha256';script-src-attr 'report-sample' 'report-sha256';style-src 'self' 'report-sample' 'report-sha256';style-src-attr 'report-sample' 'report-sha256';upgrade-insecure-requests;worker-src 'self'",
+ "Content-Security-Policy-Report-Only": "base-uri 'none';default-src 'report-sample' 'report-sha256';form-action 'none';frame-ancestors 'none';report-to default;report-uri https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org;require-trusted-types-for 'script'",
+ "Cross-Origin-Embedder-Policy": "require-corp;report-to=default",
+ "Cross-Origin-Opener-Policy": "same-origin;report-to=default",
+ "Cross-Origin-Resource-Policy": "same-origin",
+ "Document-Isolation-Policy": "isolate-and-require-corp;report-to=\"default\"",
+ "Document-Policy-Report-Only": "*;report-to=default",
+ "Integrity-Policy": "blocked-destinations=(),endpoints=(default)",
+ "Integrity-Policy-Report-Only": "blocked-destinations=(script style),endpoints=(default)",
+ "NEL": "{\"failure_fraction\":1,\"include_subdomains\":true,\"max_age\":31536000,\"report_to\":\"default\",\"success_fraction\":0}",
+ "Origin-Agent-Cluster": "?1",
+ "Permissions-Policy-Report-Only": "accelerometer=(),all-screens-capture=(),ambient-light-sensor=(),aria-notify=(),attribution-reporting=(),autofill=(),autoplay=(),battery=(),bluetooth=(),browsing-topics=(),camera=(),captured-surface-control=(),ch-ua=(),ch-ua-arch=(),ch-ua-bitness=(),ch-ua-full-version=(),ch-ua-full-version-list=(),ch-ua-high-entropy-values=(),ch-ua-mobile=(),ch-ua-model=(),ch-ua-platform=(),ch-ua-platform-version=(),clipboard-read=(),clipboard-write=(),compute-pressure=(),cross-origin-isolated=(),deferred-fetch=(),digital-credentials-create=(),digital-credentials-get=(),direct-sockets=(),display-capture=(),encrypted-media=(),execution-while-not-rendered=(),execution-while-out-of-viewport=(),focus-without-user-activation=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),hid=(),identity-credentials-get=(),idle-detection=(),interest-cohort=(),join-ad-interest-group=(),keyboard-map=(),language-detector=(),language-model=(),local-fonts=(),local-network=(),local-network-access=(),loopback-network=(),magnetometer=(),manual-text=(),mediasession=(),microphone=(),midi=(),monetization=(),navigation-override=(),on-device-speech-recognition=(),otp-credentials=(),payment=(),picture-in-picture=(),private-state-token-issuance=(),private-state-token-redemption=(),publickey-credentials-create=(),publickey-credentials-get=(),rewriter=(),run-ad-auction=(),screen-wake-lock=(),serial=(),smart-card=(),speaker-selection=(),storage-access=(),summarizer=(),sync-script=(),sync-xhr=(),tools=(),translator=(),trust-token-redemption=(),unload=(),usb=(),vertical-scroll=(),web-share=(),window-management=(),writer=(),xr-spatial-tracking=()",
+ "Referrer-Policy": "no-referrer",
+ "Report-To": "{\"endpoints\":[{\"url\":\"https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org\"}],\"group\":\"default\",\"include_subdomains\":true,\"max_age\":31536000}",
+ "Reporting-Endpoints": "default=\"https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org\"",
+ "Require-Document-Policy-Report-Only": "*;report-to=default",
+ "Scripting-Policy": "report-to=default",
+ "Scripting-Policy-Report-Only": "eval=blocked, report-to=default",
+ "Strict-Transport-Security": "max-age=31536000;includeSubDomains;preload",
+ "X-Content-Type-Options": "nosniff",
+ "X-DNS-Prefetch-Control": "off",
+ "X-Download-Options": "noopen",
+ "X-Frame-Options": "DENY",
+ "X-Permitted-Cross-Domain-Policies": "none"
+ }
+ }
+}
diff --git a/websites/datastream.js.org/.tardisec.sveltekit.json b/websites/datastream.js.org/.tardisec.sveltekit.json
new file mode 100644
index 0000000..7f3ec17
--- /dev/null
+++ b/websites/datastream.js.org/.tardisec.sveltekit.json
@@ -0,0 +1,37 @@
+{
+ "kit": {
+ "csp": {
+ "mode": "hash",
+ "directives": {
+ "base-uri": ["none"],
+ "connect-src": ["self"],
+ "default-src": ["report-sample", "'report-sha256'"],
+ "form-action": ["self"],
+ "frame-ancestors": ["none"],
+ "img-src": ["self"],
+ "manifest-src": ["self"],
+ "report-to": ["default"],
+ "report-uri": [
+ "https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org"
+ ],
+ "script-src": ["self", "report-sample", "'report-sha256'"],
+ "script-src-attr": ["report-sample", "'report-sha256'"],
+ "style-src": ["self", "report-sample", "'report-sha256'"],
+ "style-src-attr": ["report-sample", "'report-sha256'"],
+ "upgrade-insecure-requests": true,
+ "worker-src": ["self"]
+ },
+ "reportOnly": {
+ "base-uri": ["none"],
+ "default-src": ["report-sample", "'report-sha256'"],
+ "form-action": ["none"],
+ "frame-ancestors": ["none"],
+ "report-to": ["default"],
+ "report-uri": [
+ "https://9a3890fa-a783-51c2-b20f-0dbe457d526f.report-to.org"
+ ],
+ "require-trusted-types-for": ["script"]
+ }
+ }
+ }
+}
diff --git a/websites/datastream.js.org/package.json b/websites/datastream.js.org/package.json
index 906d45a..554c530 100644
--- a/websites/datastream.js.org/package.json
+++ b/websites/datastream.js.org/package.json
@@ -2,8 +2,12 @@
"name": "datastream.js.org",
"description": "SvelteKit SSR",
"private": true,
- "version": "0.5.0",
+ "version": "0.6.1",
"type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "engineStrict": true,
"scripts": {
"start": "vite dev",
"preview": "vite preview",
@@ -14,36 +18,31 @@
"build:styles:extract": "ds extract --input-dir ./.svelte-kit --theme ./src/styles/theme.css --output-dir src/styles/",
"build:styles:optimize": "ds optimize-styles ./.svelte-kit",
"release:login": "wrangler login --browser false",
- "release:domain": "curl https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/pages/projects/datastream/domains -H 'Content-Type: application/json' -H 'Authorization: Bearer ${API_KEY_W_PAGES_EDIT}' -d '{\"name\": \"datastream.js.org\"}'",
+ "release:domain": "curl https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/pages/projects/datastreamjs/domains -H 'Content-Type: application/json' -H 'Authorization: Bearer ${API_KEY_W_PAGES_EDIT}' -d '{\"name\": \"datastream.js.org\"}'",
"release:deploy": "wrangler pages deploy"
},
"dependencies": {
"@plausible-analytics/tracker": "0.4.5",
- "@willfarrell-ds/svelte": "0.0.0-alpha.6",
- "@willfarrell-ds/vanilla": "0.0.0-alpha.6",
+ "@willfarrell-ds/svelte": "0.0.0-alpha.8",
+ "@willfarrell-ds/vanilla": "0.0.0-alpha.8",
"hast-util-to-string": "3.0.1",
"mdast-util-to-string": "4.0.0",
- "uuid": "14.0.0"
+ "uuid": "14.0.1"
},
"devDependencies": {
"@sveltejs/adapter-cloudflare": "^7.0.0",
- "@sveltejs/kit": "^2.64.0",
+ "@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
- "@willfarrell-ds/cli": "0.0.0-alpha.6",
- "esbuild": "^0.28.1",
+ "@willfarrell-ds/cli": "0.0.0-alpha.8",
+ "esbuild": "^0.28.0",
"mdsvex": "^0.12.0",
- "svelte": "^5.56.3",
+ "svelte": "^5.55.7",
"svgo": "^4.0.0",
- "vite": "^8.0.16",
+ "vite": "^8.0.12",
"vite-plugin-llms": "^1.0.0",
- "vite-plugin-mkcert": "^2.1.0",
+ "vite-plugin-mkcert": "^2.0.0",
"vite-plugin-sitemap": "^0.8.0",
"vite-plugin-sri": "^0.0.2",
- "wrangler": "^4.99.0"
- },
- "overrides": {
- "@sveltejs/kit": {
- "cookie": "0.7.2"
- }
+ "wrangler": "^4.0.0"
}
}
diff --git a/websites/datastream.js.org/src/components/BodyFooter.svelte b/websites/datastream.js.org/src/components/BodyFooter.svelte
index b5b4cc8..40e9916 100644
--- a/websites/datastream.js.org/src/components/BodyFooter.svelte
+++ b/websites/datastream.js.org/src/components/BodyFooter.svelte
@@ -2,10 +2,6 @@
import BodyFooter from "@design-system/components/BodyFooter.svelte";
import Image from "@design-system/components/Image.svelte";
import A from "@design-system/elements/a.svelte";
-import { page } from "$app/state";
-
-const { children } = $props();
-const { url, params, data, form } = page;
const navLinks = {
Docs: {
diff --git a/websites/datastream.js.org/src/components/BodyHeader.svelte b/websites/datastream.js.org/src/components/BodyHeader.svelte
index 93c02cf..3fae7c6 100644
--- a/websites/datastream.js.org/src/components/BodyHeader.svelte
+++ b/websites/datastream.js.org/src/components/BodyHeader.svelte
@@ -12,7 +12,7 @@ import Span from "@design-system/elements/span.svelte";
import webComponentUrl from "@willfarrell-ds/vanilla/components/ds-input-focus.js?worker&url";
import { page } from "$app/state";
-const { params, data, form } = page;
+const { data, form } = page;
const navTopLinks = {
[`v${data.version}`]: true,
diff --git a/websites/datastream.js.org/src/components/docs/AsideNav.svelte b/websites/datastream.js.org/src/components/docs/AsideNav.svelte
index 3d46123..278d21f 100644
--- a/websites/datastream.js.org/src/components/docs/AsideNav.svelte
+++ b/websites/datastream.js.org/src/components/docs/AsideNav.svelte
@@ -1,8 +1,5 @@
diff --git a/websites/datastream.js.org/src/routes/+page.svelte b/websites/datastream.js.org/src/routes/+page.svelte
index 51d4580..91872bf 100644
--- a/websites/datastream.js.org/src/routes/+page.svelte
+++ b/websites/datastream.js.org/src/routes/+page.svelte
@@ -190,6 +190,26 @@ await pipeline(streams)`;
string
String streams, splitting, replacing, and counting.
+
+ arrow
+ Apache Arrow record batch transform streams.
+
+
+ duckdb
+ DuckDB writable streams for rows and Arrow batches.
+
+
+ kafka
+ Kafka producer and consumer streams.
+
+
+ protobuf
+ Protobuf encode, decode, and length-prefix framing.
+
+
+ schema-registry
+ Confluent and Glue Schema Registry framing.
+
diff --git a/websites/datastream.js.org/src/routes/docs/+layout.svelte b/websites/datastream.js.org/src/routes/docs/+layout.svelte
index 163c02b..895123d 100644
--- a/websites/datastream.js.org/src/routes/docs/+layout.svelte
+++ b/websites/datastream.js.org/src/routes/docs/+layout.svelte
@@ -1,5 +1,6 @@
diff --git a/websites/datastream.js.org/src/routes/docs/packages/arrow/+page.md b/websites/datastream.js.org/src/routes/docs/packages/arrow/+page.md
new file mode 100644
index 0000000..a9dc332
--- /dev/null
+++ b/websites/datastream.js.org/src/routes/docs/packages/arrow/+page.md
@@ -0,0 +1,123 @@
+---
+title: arrow
+description: Apache Arrow record batch transform streams.
+---
+
+Apache Arrow record batch transform streams. Convert rows to and from Arrow `RecordBatch` objects and detect a schema from sampled data.
+
+## Install
+
+```bash
+npm install @datastream/arrow apache-arrow
+```
+
+`apache-arrow` is a peer dependency.
+
+## `arrowDetectSchemaStream` PassThrough
+
+Samples the first rows of the stream and infers an Arrow `Schema`. Rows pass through unchanged. Accepts either arrays (columns become `column0`, `column1`, …) or objects (columns become object keys).
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `sampleSize` | `number` | `100` | Number of rows to buffer before sealing the schema |
+| `resultKey` | `string` | `"arrowDetectSchema"` | Key in pipeline result |
+
+### Result
+
+```javascript
+{ schema: Schema | null, fields: string[] | null }
+```
+
+### Type inference
+
+| Value | Arrow type |
+|-------|------------|
+| `boolean` | `Bool` |
+| Integer in signed 32-bit range | `Int32` |
+| Integer outside 32-bit range / non-integer `number` | `Float64` |
+| `Date` | `TimestampMillisecond` |
+| Anything else | `Utf8` |
+
+Integers outside the signed 32-bit range widen to `Float64` (exact up to 2^53) instead of silently wrapping inside an `Int32` builder.
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { arrowDetectSchemaStream } from '@datastream/arrow'
+
+const detect = arrowDetectSchemaStream()
+
+const result = await pipeline([
+ createReadableStream([
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bob' },
+ ]),
+ detect,
+])
+
+console.log(result.arrowDetectSchema.fields) // ['id', 'name']
+```
+
+## `arrowBatchFromArrayStream` Transform
+
+Builds Arrow `RecordBatch` objects from incoming array rows. Each row is an array of column values in schema field order.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `schema` | `Schema \| () => Schema` | — | Arrow schema, or a lazy function returning one (required) |
+| `batchSize` | `number` | `10000` | Rows per emitted `RecordBatch` |
+
+## `arrowBatchFromObjectStream` Transform
+
+Builds Arrow `RecordBatch` objects from incoming object rows, mapping each schema field name to the matching object key.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `schema` | `Schema \| () => Schema` | — | Arrow schema, or a lazy function returning one (required) |
+| `batchSize` | `number` | `10000` | Rows per emitted `RecordBatch` |
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { arrowDetectSchemaStream, arrowBatchFromObjectStream } from '@datastream/arrow'
+
+const detect = arrowDetectSchemaStream()
+
+await pipeline([
+ createReadableStream(rows),
+ detect,
+ arrowBatchFromObjectStream({
+ schema: () => detect.result().value.schema,
+ }),
+ // ... e.g. duckdbArrowInsertStream
+])
+```
+
+## `arrowToArrayStream` Transform
+
+Expands each incoming `RecordBatch` into array rows (one array of column values per row).
+
+## `arrowToObjectStream` Transform
+
+Expands each incoming `RecordBatch` into object rows, keyed by the batch schema's field names.
+
+### Example
+
+```javascript
+import { pipeline } from '@datastream/core'
+import { arrowToObjectStream } from '@datastream/arrow'
+
+await pipeline([
+ recordBatchReadableStream,
+ arrowToObjectStream(),
+ // ... each chunk is { id, name, ... }
+])
+```
diff --git a/websites/datastream.js.org/src/routes/docs/packages/duckdb/+page.md b/websites/datastream.js.org/src/routes/docs/packages/duckdb/+page.md
new file mode 100644
index 0000000..938403e
--- /dev/null
+++ b/websites/datastream.js.org/src/routes/docs/packages/duckdb/+page.md
@@ -0,0 +1,124 @@
+---
+title: duckdb
+description: DuckDB writable streams for inserting rows and Arrow record batches.
+---
+
+DuckDB writable streams. Insert rows or Apache Arrow `RecordBatch` objects directly into a DuckDB table.
+
+## Install
+
+```bash
+npm install @datastream/duckdb
+```
+
+DuckDB is provided through optional peer dependencies. Install the runtime for your platform:
+
+| Platform | Package |
+|----------|---------|
+| Node.js | `@duckdb/node-api` |
+| Browser | `@duckdb/duckdb-wasm` |
+
+The Arrow insert stream also requires `apache-arrow`.
+
+## `duckdbConnect`
+
+Opens a DuckDB connection. Defaults to an in-memory database.
+
+| Argument | Type | Default | Description |
+|----------|------|---------|-------------|
+| `path` | `string` | `":memory:"` | Database file path, or `:memory:` |
+| `options` | `object` | — | DuckDB instance options |
+
+```javascript
+import { duckdbConnect } from '@datastream/duckdb'
+
+const db = await duckdbConnect() // in-memory
+```
+
+## `duckdbAppenderStream` Writable
+
+Appends array or object rows into an existing table using the DuckDB appender. Returns a Promise resolving to the writable stream.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `db` | `Connection` | — | DuckDB connection from `duckdbConnect` (required) |
+| `table` | `string` | — | Target table name (required) |
+| `schema` | `Schema \| () => Schema` | — | Arrow schema; when provided and the table does not exist, the table is created from it |
+
+When no `schema` is given, the target table must already exist; its column names are read from the table. Array rows are inserted in column order, object rows by column name.
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { duckdbConnect, duckdbAppenderStream } from '@datastream/duckdb'
+
+const db = await duckdbConnect()
+await db.run('CREATE TABLE people (id INTEGER, name VARCHAR)')
+
+await pipeline([
+ createReadableStream([
+ { id: 1, name: 'Alice' },
+ { id: 2, name: 'Bob' },
+ ]),
+ await duckdbAppenderStream({ db, table: 'people' }),
+])
+```
+
+## `duckdbArrowInsertStream` Writable
+
+Inserts Apache Arrow `RecordBatch` objects into a table. Returns a Promise resolving to the writable stream. Pairs with `arrowBatchFromObjectStream` / `arrowBatchFromArrayStream` from `@datastream/arrow`.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `db` | `Connection` | — | DuckDB connection from `duckdbConnect` (required) |
+| `table` | `string` | — | Target table name (required) |
+| `schema` | `Schema \| () => Schema` | — | Arrow schema; when provided and the table does not exist, the table is created from it |
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { arrowDetectSchemaStream, arrowBatchFromObjectStream } from '@datastream/arrow'
+import { duckdbConnect, duckdbArrowInsertStream } from '@datastream/duckdb'
+
+const db = await duckdbConnect()
+const detect = arrowDetectSchemaStream()
+
+await pipeline([
+ createReadableStream(rows),
+ detect,
+ arrowBatchFromObjectStream({ schema: () => detect.result().value.schema }),
+ await duckdbArrowInsertStream({
+ db,
+ table: 'events',
+ schema: () => detect.result().value.schema,
+ }),
+])
+```
+
+## Arrow type mapping
+
+When creating a table from an Arrow schema, types map to DuckDB columns as follows:
+
+| Arrow type | DuckDB type |
+|------------|-------------|
+| `Bool` | `BOOLEAN` |
+| `Int8` / `Int16` / `Int32` / `Int64` | `TINYINT` / `SMALLINT` / `INTEGER` / `BIGINT` |
+| `Uint8` / `Uint16` / `Uint32` / `Uint64` | `UTINYINT` / `USMALLINT` / `UINTEGER` / `UBIGINT` |
+| `Float32` / `Float64` | `REAL` / `DOUBLE` |
+| `DateDay` / `DateMillisecond` | `DATE` |
+| `TimestampSecond` / `TimestampMillisecond` / `TimestampMicrosecond` / `TimestampNanosecond` | `TIMESTAMP_S` / `TIMESTAMP_MS` / `TIMESTAMP` / `TIMESTAMP_NS` |
+| anything else | `VARCHAR` |
+
+## Platform support
+
+| Feature | Node.js | Browser |
+|---------|---------|---------|
+| `duckdbConnect` | `@duckdb/node-api` | `@duckdb/duckdb-wasm` |
+| `duckdbAppenderStream` | yes | yes |
+| `duckdbArrowInsertStream` | yes | yes (requires `apache-arrow`) |
diff --git a/websites/datastream.js.org/src/routes/docs/packages/kafka/+page.md b/websites/datastream.js.org/src/routes/docs/packages/kafka/+page.md
new file mode 100644
index 0000000..5a34c38
--- /dev/null
+++ b/websites/datastream.js.org/src/routes/docs/packages/kafka/+page.md
@@ -0,0 +1,128 @@
+---
+title: kafka
+description: Kafka producer and consumer streams built on kafkajs.
+---
+
+Kafka producer and consumer streams built on [kafkajs](https://kafka.js.org). Produce messages from a writable stream and consume them from a readable stream with end-to-end backpressure.
+
+## Install
+
+```bash
+npm install @datastream/kafka kafkajs
+```
+
+`kafkajs` is an optional peer dependency.
+
+## `kafkaConnect`
+
+Creates a Kafka client and connects a producer and/or consumer. Returns a connection object. A consumer is created only when a `groupId` is provided; the producer is created unless `producer` is set to `false`.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `brokers` | `string[]` | — | Broker addresses |
+| `clientId` | `string` | — | Client identifier |
+| `ssl` | `object` | — | TLS configuration |
+| `sasl` | `object` | — | SASL authentication (see `@datastream/aws` MSK IAM) |
+| `groupId` | `string` | — | Consumer group id; required to create a consumer |
+| `producer` | `object \| false` | `{}` | Producer options, or `false` to skip the producer |
+| `consumer` | `object` | `{}` | Additional consumer options |
+
+### Result
+
+```javascript
+{ kafka, producer, consumer, disconnect }
+```
+
+Call `await connection.disconnect()` to disconnect the producer and consumer.
+
+### Example
+
+```javascript
+import { kafkaConnect } from '@datastream/kafka'
+
+const { producer, consumer, disconnect } = await kafkaConnect({
+ brokers: ['localhost:9092'],
+ clientId: 'my-app',
+ groupId: 'my-group',
+})
+```
+
+## `kafkaProduceStream` Writable
+
+Batches messages and sends them to a topic. Returns a Promise resolving to the writable stream. Chunks may be a `Uint8Array`, a `string`, or a `{ value, key?, headers?, partition? }` message object.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `producer` | `Producer` | — | Producer from `kafkaConnect` (required) |
+| `topic` | `string` | — | Destination topic (required) |
+| `batchSize` | `number` | `100` | Messages buffered before a `send` |
+| `acks` | `-1 \| 0 \| 1` | `-1` | Acknowledgements (`-1` = all in-sync replicas) |
+| `compression` | `0 \| 1 \| 2 \| 3 \| 4` | — | None, GZIP, Snappy, LZ4, ZSTD |
+| `timeout` | `number` | — | Per-request send timeout in ms |
+
+On a failed send the thrown error carries the un-sent batch on `err.failedMessages` so callers can re-queue instead of losing data.
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { kafkaConnect, kafkaProduceStream } from '@datastream/kafka'
+
+const { producer } = await kafkaConnect({ brokers: ['localhost:9092'] })
+
+await pipeline([
+ createReadableStream([
+ { value: 'event-1' },
+ { value: 'event-2' },
+ ]),
+ await kafkaProduceStream({ producer, topic: 'events' }),
+])
+```
+
+## `kafkaConsumeStream` Readable
+
+Subscribes to one or more topics and emits each message as a chunk. Returns a Promise resolving to a readable stream with a `stop()` method. Backpressure propagates from the downstream consumer through kafkajs to the broker.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `consumer` | `Consumer` | — | Consumer from `kafkaConnect` (required) |
+| `topics` | `string \| string[]` | — | Topic(s) to subscribe to (required) |
+| `fromBeginning` | `boolean` | `false` | Start from the earliest offset |
+| `autoCommit` | `boolean` | `true` | Auto-commit offsets |
+| `partitionsConsumedConcurrently` | `number` | `1` | Concurrent partition processing |
+| `signal` | `AbortSignal` | — | Aborting stops the consumer |
+
+### Emitted chunk
+
+```javascript
+{ topic, partition, offset, key, value, headers, timestamp }
+```
+
+### Example
+
+```javascript
+import { kafkaConnect, kafkaConsumeStream } from '@datastream/kafka'
+
+const { consumer } = await kafkaConnect({
+ brokers: ['localhost:9092'],
+ groupId: 'my-group',
+})
+
+const stream = await kafkaConsumeStream({ consumer, topics: 'events' })
+
+for await (const message of stream) {
+ console.log(message.value)
+}
+
+await stream.stop()
+```
+
+## Platform support
+
+Available on Node.js (via `kafkajs`). Not available in the browser.
diff --git a/websites/datastream.js.org/src/routes/docs/packages/protobuf/+page.md b/websites/datastream.js.org/src/routes/docs/packages/protobuf/+page.md
new file mode 100644
index 0000000..72a1a21
--- /dev/null
+++ b/websites/datastream.js.org/src/routes/docs/packages/protobuf/+page.md
@@ -0,0 +1,110 @@
+---
+title: protobuf
+description: Protobuf encode/decode and length-prefix framing streams.
+---
+
+Protobuf encode/decode transform streams and length-prefix framing for delimited Protobuf records. Works with a [protobufjs](https://protobufjs.github.io/protobuf.js/)-style `Type` (anything exposing `encode`, `decode`, and `create`).
+
+## Install
+
+```bash
+npm install @datastream/protobuf
+```
+
+Bring your own Protobuf runtime (for example `protobufjs`) to obtain message `Type` objects.
+
+## Type input
+
+Each encode/decode stream takes a `Type`. It may be:
+
+- a static `Type` value (cached on the hot path), or
+- a function `(chunk) => Type` (sync or async), re-invoked per chunk so you can select a different message type per record (for example from a Schema Registry envelope).
+
+## `protobufEncodeStream` Transform
+
+Encodes each incoming message object into Protobuf wire bytes (`Uint8Array`).
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `Type` | `Type \| (chunk) => Type` | — | Protobuf message type, or a per-chunk resolver (required) |
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { protobufEncodeStream } from '@datastream/protobuf'
+
+await pipeline([
+ createReadableStream([{ id: 1, name: 'Alice' }]),
+ protobufEncodeStream({ Type: Person }),
+])
+```
+
+## `protobufDecodeStream` Transform
+
+Decodes Protobuf wire bytes into message objects.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `Type` | `Type \| (chunk) => Type` | — | Protobuf message type, or a per-chunk resolver (required) |
+| `payload` | `(chunk) => Uint8Array` | identity | Extract the protobuf bytes from each chunk (e.g. from a registry envelope) |
+| `maxOutputSize` | `number` | — | Maximum total input bytes; decoding aborts with an error when exceeded |
+
+#### Output size protection
+
+Decoding untrusted Protobuf can amplify a small input into large objects. Set `maxOutputSize` to bound total decoded volume by input bytes and abort before memory is exhausted.
+
+### Example
+
+```javascript
+import { protobufDecodeStream } from '@datastream/protobuf'
+
+protobufDecodeStream({ Type: Person, maxOutputSize: 10 * 1024 * 1024 })
+```
+
+## `protobufLengthPrefixFrameStream` Transform
+
+Prefixes each chunk with a base-128 varint length, producing a self-delimiting stream of framed records (the same framing Kafka and gRPC-style transports use).
+
+## `protobufLengthPrefixUnframeStream` Transform
+
+Reassembles a varint-length-prefixed byte stream back into individual record frames. Handles records split across chunk boundaries.
+
+### Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `maxMessageSize` | `number` | — | Reject any frame whose declared length exceeds this, aborting with an error |
+
+Throws on flush if trailing bytes do not form a complete frame, and on a varint that overflows.
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import {
+ protobufEncodeStream,
+ protobufLengthPrefixFrameStream,
+ protobufLengthPrefixUnframeStream,
+ protobufDecodeStream,
+} from '@datastream/protobuf'
+
+// Encode + frame on the way out
+await pipeline([
+ createReadableStream(messages),
+ protobufEncodeStream({ Type: Person }),
+ protobufLengthPrefixFrameStream(),
+ // ... write framed bytes
+])
+
+// Unframe + decode on the way in
+await pipeline([
+ framedByteStream,
+ protobufLengthPrefixUnframeStream({ maxMessageSize: 1 * 1024 * 1024 }),
+ protobufDecodeStream({ Type: Person }),
+])
+```
diff --git a/websites/datastream.js.org/src/routes/docs/packages/schema-registry/+page.md b/websites/datastream.js.org/src/routes/docs/packages/schema-registry/+page.md
new file mode 100644
index 0000000..4afc074
--- /dev/null
+++ b/websites/datastream.js.org/src/routes/docs/packages/schema-registry/+page.md
@@ -0,0 +1,98 @@
+---
+title: schema-registry
+description: Confluent and AWS Glue Schema Registry framing streams.
+---
+
+Framing and unframing transform streams for the Confluent and AWS Glue Schema Registry wire formats. Each input chunk is treated as one envelope (one Kafka message = one chunk = one framed record).
+
+## Install
+
+```bash
+npm install @datastream/schema-registry
+```
+
+## Confluent format
+
+5-byte header: a `0x00` magic byte followed by a big-endian unsigned 32-bit schema id, then the payload bytes.
+
+### `confluentFrameStream` Transform
+
+Prepends the Confluent header to each payload chunk.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `schemaId` | `number` | — | Unsigned 32-bit schema id (required) |
+| `resultKey` | `string` | `"confluentSchemaId"` | Key in pipeline result |
+
+### `confluentUnframeStream` Transform
+
+Validates the magic byte, reads the schema id, and emits a `{ schemaId, payload }` envelope.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `resultKey` | `string` | `"confluentSchemaId"` | Key in pipeline result |
+
+### Emitted envelope
+
+```javascript
+{ schemaId: number, payload: Uint8Array }
+```
+
+### Example
+
+```javascript
+import { pipeline, createReadableStream } from '@datastream/core'
+import { confluentUnframeStream } from '@datastream/schema-registry'
+import { protobufDecodeStream } from '@datastream/protobuf'
+
+await pipeline([
+ framedByteStream,
+ confluentUnframeStream(),
+ protobufDecodeStream({
+ // pick the Type per message from the envelope schemaId
+ Type: (envelope) => registry.get(envelope.schemaId),
+ payload: (envelope) => envelope.payload,
+ }),
+])
+```
+
+## Glue format
+
+18-byte header: a `0x03` magic byte, a 1-byte compression flag (`none` or `zlib`), and a 16-byte schema version UUID, then the payload bytes.
+
+### `glueFrameStream` Transform
+
+Prepends the Glue header to each payload chunk, optionally deflating the payload.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `schemaVersionId` | `string` | — | Schema version UUID (required) |
+| `compression` | `"none" \| "zlib"` | `"none"` | Payload compression |
+| `resultKey` | `string` | `"glueSchemaVersionId"` | Key in pipeline result |
+
+### `glueUnframeStream` Transform
+
+Validates the magic byte, reads the schema version UUID and compression flag, inflates `zlib` payloads, and emits a `{ schemaVersionId, compression, payload }` envelope.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `maxDecompressedBytes` | `number` | `10485760` (10MB) | Maximum decompressed payload size; aborts with an error when exceeded |
+| `resultKey` | `string` | `"glueSchemaVersionId"` | Key in pipeline result |
+
+#### Decompression protection
+
+A malicious zlib payload can expand to far more than its framed size. `maxDecompressedBytes` caps the inflated output and aborts before memory is exhausted. Always keep this bounded for untrusted input.
+
+### Emitted envelope
+
+```javascript
+{ schemaVersionId: string, compression: 'none' | 'zlib', payload: Uint8Array }
+```
+
+## Per-chunk envelopes vs `result()`
+
+Unframe streams emit `{ schemaId | schemaVersionId, payload }` envelopes downstream so a decoder can select the right schema per chunk. The `.result()` accessor is exposed for parity with the other detect streams, but it reflects only the most recently seen envelope and is racy under backpressure — prefer the per-chunk envelope when wiring a decoder.
+
+## Platform support
+
+Works on both Node.js and the browser; compression uses the platform `CompressionStream` / `DecompressionStream`.
diff --git a/websites/datastream.js.org/src/routes/search/+page.svelte b/websites/datastream.js.org/src/routes/search/+page.svelte
index bc7edb5..ecca25a 100644
--- a/websites/datastream.js.org/src/routes/search/+page.svelte
+++ b/websites/datastream.js.org/src/routes/search/+page.svelte
@@ -4,7 +4,6 @@ import H1 from "@design-system/components/Heading1.svelte";
import H2 from "@design-system/components/Heading2.svelte";
import LayoutCenter from "@design-system/components/LayoutCenter.svelte";
import A from "@design-system/elements/a.svelte";
-import Li from "@design-system/elements/li.svelte";
import P from "@design-system/elements/p.svelte";
import Section from "@design-system/elements/section.svelte";
import Span from "@design-system/elements/span.svelte";
diff --git a/websites/datastream.js.org/src/styles/below.css b/websites/datastream.js.org/src/styles/below.css
index a45bf3b..d416549 100644
--- a/websites/datastream.js.org/src/styles/below.css
+++ b/websites/datastream.js.org/src/styles/below.css
@@ -1,3 +1,4 @@
+@import "@willfarrell-ds/vanilla/elements/blockquote.css";
@import "@willfarrell-ds/vanilla/elements/code.css";
@import "@willfarrell-ds/vanilla/elements/details.css";
@import "@willfarrell-ds/vanilla/elements/form.css";
@@ -11,7 +12,6 @@
@import "@willfarrell-ds/vanilla/elements/p.css";
@import "@willfarrell-ds/vanilla/elements/picture.css";
@import "@willfarrell-ds/vanilla/elements/pre.css";
-@import "@willfarrell-ds/vanilla/elements/s.css";
@import "@willfarrell-ds/vanilla/elements/select.css";
@import "@willfarrell-ds/vanilla/elements/table.css";
@import "@willfarrell-ds/vanilla/classes/span.badge.css";
diff --git a/websites/datastream.js.org/svelte.config.js b/websites/datastream.js.org/svelte.config.js
index f26ddfe..82825f9 100644
--- a/websites/datastream.js.org/svelte.config.js
+++ b/websites/datastream.js.org/svelte.config.js
@@ -1,9 +1,9 @@
import { resolve } from "node:path";
import adapter from "@sveltejs/adapter-cloudflare";
import { mdsvex } from "mdsvex";
+import tardisec from "./.tardisec.sveltekit.json" with { type: "json" };
import { rehypeAddHeadingIds } from "./src/lib/rehype-add-heading-ids.js";
import { remarkExtractHeadings } from "./src/lib/remark-extract-headings.js";
-import tardisec from "./tardisec.json" with { type: "json" };
const domain = process.env.ORIGIN ?? "datastream.js.org";
const origin = domain;
@@ -18,26 +18,7 @@ const config = {
"@styles": resolve("./src/styles"),
},
appDir: "_",
- csp: {
- ...tardisec["svelte.config.js"]["Content-Security-Policy"],
- mode: "hash",
- directives: {
- "default-src": ["none"],
- "base-uri": ["none"],
- "connect-src": ["self"],
- "form-action": ["self"],
- "frame-ancestors": ["none"],
- "img-src": ["self"],
- "manifest-src": ["self"],
- "script-src": ["self"],
- "script-src-attr": ["report-sample"],
- "style-src": ["self"],
- "style-src-attr": ["report-sample"],
- "upgrade-insecure-requests": true,
- "worker-src": ["self"],
- "report-to": ["default"],
- },
- },
+ csp: tardisec.kit.csp,
csrf: {
trustedOrigins: [origin],
},