From 82b9edcfd5e76134595c0a87cabd6296572c7c65 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 10 Jul 2026 19:05:25 -0400 Subject: [PATCH 1/5] feat: add opt-in snapshot policies --- .changeset/calm-snapshots-flow.md | 5 + .gitignore | 19 +++ .tool-versions | 2 - AGENTS.md | 85 ++++++++++++ CLAUDE.md | 3 + CONTRIBUTING.md | 44 ++++++ README.md | 26 +++- bun.lock | 44 +++++- docs/snapshot-policies.md | 87 ++++++++++++ mise.toml | 3 + package.json | 5 +- src/utils/streaming-json-parser.ts | 106 +++++++++++--- tests/iterate.test.ts | 39 +++++- tests/packed-consumer/consumer-zod3.ts | 6 +- tests/packed-consumer/consumer.ts | 13 +- tests/snapshot-policy.benchmark.ts | 114 ++++++++++++++++ tests/snapshot-policy.test.ts | 182 +++++++++++++++++++++++++ tsconfig.build.json | 15 ++ tsconfig.json | 1 - tsup.config.ts | 3 +- 20 files changed, 766 insertions(+), 36 deletions(-) create mode 100644 .changeset/calm-snapshots-flow.md delete mode 100644 .tool-versions create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 docs/snapshot-policies.md create mode 100644 mise.toml create mode 100644 tests/snapshot-policy.benchmark.ts create mode 100644 tests/snapshot-policy.test.ts create mode 100644 tsconfig.build.json diff --git a/.changeset/calm-snapshots-flow.md b/.changeset/calm-snapshots-flow.md new file mode 100644 index 0000000..8daa4b6 --- /dev/null +++ b/.changeset/calm-snapshots-flow.md @@ -0,0 +1,5 @@ +--- +"schema-stream": minor +--- + +Add opt-in completed-value, byte-threshold, and final-only snapshot policies to `parse()` and `iterate()` while preserving per-input-chunk emission as the default. diff --git a/.gitignore b/.gitignore index 4454787..829e19e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,16 @@ dist .env .env.* !.env.example +.mise.local.toml + +# Local credentials and signing material +*.key +*.p12 +*.pfx +*.crt +*.cer +*.mobileprovision +*.secrets.* # Package-manager and debug output npm-debug.log* @@ -20,3 +30,12 @@ yarn-error.log* .idea .vscode *.pem + +# Local agent and profiling state +.agents/ +.claude/ +.codex/ +*.cpuprofile +*.heapprofile +*.heapsnapshot +*.trace diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 647989a..0000000 --- a/.tool-versions +++ /dev/null @@ -1,2 +0,0 @@ -bun 1.3.14 -nodejs 24.18.0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..81b2acb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# Schema Stream Agent Guide + +## Project contract + +Schema Stream is a small, public, standalone TypeScript library for progressively parsing streamed JSON into typed, schema-shaped snapshots. Keep changes focused on the library, its tests, its documentation, and its release tooling. + +- Treat this as a public OSS repository. Never add private URLs, credentials, customer data, internal project names, local absolute paths, transcripts, or personal information beyond the public package metadata already committed. +- Preserve consumer compatibility across Node.js and Bun, ESM and CommonJS, Zod 3, Zod 4, and Zod Mini. +- Do not edit generated output in `dist/`; regenerate it with `bun run build`. +- Prefer small, reviewable changes. Preserve unrelated user changes in a dirty worktree. + +## Toolchain + +Use the versions declared by the repository: + +- `mise install` provisions Bun 1.3.14 and Node.js 24. +- `bun install` installs dependencies from `bun.lock`. +- TypeScript 7 is the authoritative development compiler. +- Published declarations must remain consumable by supported TypeScript 5.x projects; `bun run test:packed` verifies this with TypeScript 5.9. +- Prettier is the repository's formatter. Do not introduce a second formatter or linter without migrating the configuration, scripts, CI, and existing source in one deliberate change. + +Run repository scripts through Bun: + +```sh +bun run format +bun run format:check +bun run type-check +bun run test +bun run test:coverage +bun run build +bun run test:packed +bun run check +``` + +`bun run check` is the minimum completion gate. Run `bun run build` when exports, declarations, build configuration, or package metadata change. Run `bun run test:coverage` when behavior changes. + +## Code style and architecture + +- Use strict TypeScript. Never introduce `any`; use `unknown`, narrowing, discriminated unions, generics, and schema-derived types. +- Preserve precise consumer inference. Avoid widening literals, unnecessary assertions, broad index signatures, or generic defaults that erase information. +- Prefer named options objects when a function has multiple inputs or is likely to grow. Do not churn a stable public signature solely to satisfy this preference. +- Prefer focused functions, composition, early returns, immutable values, and clear module boundaries. +- Use function declarations for named reusable functions and arrow functions for callbacks or concise local closures. +- Avoid hidden mutable global state and unnecessary allocation in streaming hot paths. +- Keep parser state transitions explicit. Preserve chunk-boundary correctness, incremental UTF-8 decoding, backpressure, cancellation, and useful error context. +- Do not add dependencies when a platform primitive or a small local implementation is sufficient. +- Follow the configured format: two spaces, double quotes, no semicolons, and a 100-column print width. + +## Documentation and comments + +- Add useful TSDoc block comments to public classes, functions, methods, exported types, and non-obvious internal boundaries. +- Describe behavior, invariants, streaming semantics, failure modes, and inference guarantees—not a restatement of the identifier. +- Include `@param`, `@returns`, `@throws`, `@typeParam`, or examples when they add information for consumers or maintainers. +- Avoid narration-style inline comments. Use an inline comment only for a subtle invariant, protocol detail, compatibility workaround, or actionable TODO with context. +- Update README or focused files under `docs/` when public behavior, configuration, performance tradeoffs, or compatibility changes. +- Keep examples executable, type-correct, and representative of the published API. + +## Testing expectations + +Behavior changes should include the narrowest regression test plus broader coverage where the risk warrants it. Exercise relevant combinations of: + +- chunks split at every meaningful token and UTF-8 boundary; +- empty, partial, malformed, deeply nested, and very large JSON values; +- objects, arrays, primitives, nullable and optional fields, unions, records, and recursive schemas; +- snapshot policies, final snapshot behavior, cancellation, and parser errors; +- Zod 3, Zod 4, and Zod Mini; +- ESM, CommonJS, Node.js, Bun, and packed-package declaration consumption; +- sustained high-throughput streams and allocation-sensitive paths. + +Performance tests must be deterministic enough to detect major regressions without asserting fragile machine-specific timings. Prefer reporting throughput, snapshot count, and peak-memory-relevant behavior; keep correctness assertions alongside benchmarks. + +## Public API and releases + +- Treat every export and emitted declaration as public API. +- Favor additive changes. Clearly document intentional breaking changes and add a changeset with the appropriate version bump. +- Keep `package.json` exports, `files`, runtime requirements, README installation guidance, and packed-consumer tests synchronized. +- Verify the packed tarball rather than assuming a successful source build proves publishability. + +## Repository map + +- `src/`: library source and public entry points +- `tests/`: unit, integration, benchmark, and packed-consumer verification +- `docs/`: focused design and behavior documentation +- `.changeset/`: release notes and version intent +- `.github/workflows/`: CI and release automation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f7447c6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Instructions + +Read and follow [`AGENTS.md`](./AGENTS.md) as the repository's canonical agent and contributor guidance. Keep this file as a pointer so instructions do not drift between tools. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d0ef5a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to Schema Stream + +Thanks for helping improve Schema Stream. + +## Setup + +Install the repository toolchain with [mise](https://mise.jdx.dev/) and install dependencies with Bun: + +```sh +mise install +bun install +``` + +The repository develops with Bun 1.3.14, Node.js 24, and TypeScript 7. Consumers do not need TypeScript 7; the packed-package test verifies the published declarations with TypeScript 5.9. + +## Development checks + +Run the full local gate before opening a pull request: + +```sh +bun run check +bun run build +``` + +For behavior changes, also inspect coverage: + +```sh +bun run test:coverage +``` + +`bun run test:packed` builds and installs the package tarball into isolated ESM and CommonJS consumers. It verifies Zod 3, Zod 4, Zod Mini, OpenAI Agents SDK, Vercel AI SDK, and TypeScript 5.9 compatibility. + +## Changes + +- Include focused tests for fixes and new behavior. +- Preserve strong public type inference and supported runtime/schema compatibility. +- Add or update TSDoc for public APIs and document meaningful user-facing behavior. +- Add a changeset for changes that should appear in a release: + +```sh +bun run changeset +``` + +See [`AGENTS.md`](./AGENTS.md) for the complete project conventions, testing matrix, and public-OSS privacy rules. diff --git a/README.md b/README.md index f7a7d55..6e77495 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,17 @@ for await (const partial of parser.iterate(response.body!)) { across byte chunks, cancels the source when iteration ends early, and yields an independent snapshot for every input chunk. +Snapshot cadence is opt-in and shared with `parse()`. Omitting the option retains one snapshot per +input chunk: + +```typescript +parser.iterate(source, { snapshotPolicy: { mode: "value" } }) +parser.iterate(source, { snapshotPolicy: { mode: "bytes", bytes: 256 * 1024 } }) +parser.iterate(source, { snapshotPolicy: { mode: "final" } }) +``` + +See [snapshot policies](./docs/snapshot-policies.md) for exact semantics and performance tradeoffs. + ## OpenAI Agents SDK Pass the Agents SDK text stream directly to `iterate()`: @@ -180,18 +191,23 @@ for await (const bytes of snapshots) { } ``` +`parse()` accepts the same `snapshotPolicy` option as `iterate()`. + ## Development ```bash +mise install bun install -bun run type-check -bun run test:coverage -bun run build -bun run test:packed +bun run check ``` +`mise.toml` pins Bun 1.3.14 and the current Node 24 release. Maintainers type-check and emit +declarations with TypeScript 7.0.2. TypeScript is a development-only dependency, so installing +`schema-stream` does not install or require TypeScript 7. + `test:packed` installs the generated tarball into clean consumers and verifies ESM, CommonJS, -Zod 4/Mini, Zod 3, OpenAI Agents SDK, and Vercel AI SDK compatibility without contacting a model. +Zod 4/Mini, Zod 3, OpenAI Agents SDK, and Vercel AI SDK compatibility with TypeScript 5.9 without +contacting a model. This protects the declaration surface from accidental TS7-only syntax. ## License diff --git a/bun.lock b/bun.lock index f8b06c1..4a592ed 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "ai": "7.0.19", "prettier": "^3.9.5", "tsup": "^8.5.1", - "typescript": "^5.9.3", + "typescript": "7.0.2", "zod": "^4.4.3", "zod3": "npm:zod@^3.25.0", }, @@ -215,6 +215,46 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], @@ -593,7 +633,7 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], diff --git a/docs/snapshot-policies.md b/docs/snapshot-policies.md new file mode 100644 index 0000000..707e1f2 --- /dev/null +++ b/docs/snapshot-policies.md @@ -0,0 +1,87 @@ +# Snapshot policies + +Snapshot policies reduce cumulative JSON serialization when a consumer does not need an update for +every source chunk. They are optional. Omitting `snapshotPolicy` retains the 4.0 behavior: one +snapshot for every input chunk. + +The same options work with both `parse()` and `iterate()`: + +```typescript +parser.parse({ snapshotPolicy: { mode: "bytes", bytes: 256 * 1024 } }) + +for await (const snapshot of parser.iterate(source, { + snapshotPolicy: { mode: "value" } +})) { + renderProgress(snapshot) +} +``` + +## Modes + +### `chunk` + +```typescript +{ + mode: "chunk" +} +``` + +Emits after every input chunk. This is the default and is snapshot-for-snapshot compatible with +omitting the option. + +### `value` + +```typescript +{ + mode: "value" +} +``` + +Emits after an input chunk completes at least one primitive JSON value. Several values completed in +one source chunk produce one snapshot. This is useful for long streamed strings because incomplete +characters do not repeatedly serialize the entire accumulated object. + +### `bytes` + +```typescript +{ mode: "bytes", bytes: 256 * 1024 } +``` + +Emits when source bytes received since the previous snapshot meet or exceed the positive integer +threshold. Thresholds are evaluated at source-chunk boundaries; SchemaStream does not split input +chunks. A final tail smaller than the threshold is still emitted. + +### `final` + +```typescript +{ + mode: "final" +} +``` + +Emits once after the JSON parser reaches a complete document. As in 4.0, SchemaStream does not apply +the Zod schema as authoritative output validation. Validate the result explicitly or use the settled, +validated output from the producing SDK. + +## Errors, callbacks, and backpressure + +- Invalid byte thresholds throw synchronously. +- Malformed and truncated JSON reject under every policy. +- `onKeyComplete` cadence is independent of snapshot cadence. +- `iterate()` preserves source backpressure at emission boundaries and cancels its source when the + consumer returns early. +- Every yielded `iterate()` value is decoded from serialized output, so consumer mutation cannot + affect later snapshots. +- Timer-based policies are intentionally excluded because asynchronous controller lifetime, + cancellation, and deterministic backpressure semantics require a separate design. + +## Benchmark + +Run the policy comparison with an optional payload size in megabytes: + +```bash +bun tests/snapshot-policy.benchmark.ts 25 +``` + +The benchmark combines one long string with 10,000 nested records and reports source throughput, +snapshot count, and total emitted bytes for `chunk`, `value`, 256 KB, 1 MB, and `final` policies. diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..0e5f426 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +bun = "1.3.14" +node = "24" diff --git a/package.json b/package.json index 53d8ce3..6f779d3 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ }, "packageManager": "bun@1.3.14", "scripts": { - "build": "tsup", + "build": "tsup && tsc --project tsconfig.build.json", + "check": "bun run format:check && bun run type-check && bun run test && bun run test:packed", "changeset": "changeset", "clean": "rm -rf coverage dist node_modules", "dev": "tsup --watch", @@ -71,7 +72,7 @@ "ai": "7.0.19", "prettier": "^3.9.5", "tsup": "^8.5.1", - "typescript": "^5.9.3", + "typescript": "7.0.2", "zod": "^4.4.3", "zod3": "npm:zod@^3.25.0" }, diff --git a/src/utils/streaming-json-parser.ts b/src/utils/streaming-json-parser.ts index 18afdea..f9f2ce9 100644 --- a/src/utils/streaming-json-parser.ts +++ b/src/utils/streaming-json-parser.ts @@ -38,8 +38,13 @@ export type SchemaStreamOptions = { export type SchemaStreamParseOptions = { stringBufferSize?: number handleUnescapedNewLines?: boolean + snapshotPolicy?: SnapshotPolicy } +/** Controls when cumulative JSON snapshots are serialized and emitted. */ +export type SnapshotPolicy = + { mode: "chunk" } | { mode: "value" } | { mode: "bytes"; bytes: number } | { mode: "final" } + export type SchemaStreamInputChunk = string | Uint8Array export type SchemaStreamSource = @@ -278,7 +283,7 @@ export class SchemaStream { stack: StackElement[] } tokenizer: ParsedTokenInfo - }): void { + }): boolean { const valuePath = this.getPathFromStack(stack, key) this.activePath = valuePath @@ -293,6 +298,7 @@ export class SchemaStream { setPathValue(this.schemaInstance, valuePath, value) this.emitCompletion() + return !partial && valuePath.length > 0 } /** Returns a new schema-derived stub using this instance's primitive defaults. */ @@ -306,36 +312,93 @@ export class SchemaStream { ) as SchemaStreamChunk } - /** Creates a transform that emits the current schema-shaped JSON after every input chunk. */ - public parse(options: SchemaStreamParseOptions = {}): TransformStream { + private createTransform( + options: SchemaStreamParseOptions, + onSnapshot?: (snapshot: Uint8Array) => void + ): TransformStream { const textEncoder = new TextEncoder() + const snapshotPolicy = options.snapshotPolicy ?? { mode: "chunk" } + if ( + snapshotPolicy.mode === "bytes" && + (!Number.isFinite(snapshotPolicy.bytes) || + !Number.isInteger(snapshotPolicy.bytes) || + snapshotPolicy.bytes <= 0) + ) { + throw new TypeError("snapshotPolicy.bytes must be a positive, finite integer") + } const parser = new JSONParser({ stringBufferSize: options.stringBufferSize ?? 0, handleUnescapedNewLines: options.handleUnescapedNewLines ?? true }) - - parser.onToken = this.handleToken.bind(this) + let bytesSinceEmission = 0 + let completedValuesSinceEmission = 0 + let parserRevision = 0 + let emittedRevision = -1 + + parser.onToken = parsedToken => { + const completedValue = this.handleToken(parsedToken) + parserRevision += 1 + if (completedValue) { + completedValuesSinceEmission += 1 + } + } parser.onValue = () => undefined + const emitSnapshot = (controller: TransformStreamDefaultController): void => { + const snapshot = textEncoder.encode(JSON.stringify(this.schemaInstance)) + controller.enqueue(snapshot) + onSnapshot?.(snapshot) + bytesSinceEmission = 0 + completedValuesSinceEmission = 0 + emittedRevision = parserRevision + } + + const shouldEmit = (): boolean => { + if (snapshotPolicy.mode === "chunk") { + return true + } + if (snapshotPolicy.mode === "value") { + return completedValuesSinceEmission > 0 + } + if (snapshotPolicy.mode === "bytes") { + return bytesSinceEmission >= snapshotPolicy.bytes + } + return false + } + return new TransformStream({ transform: (chunk, controller): void => { try { parser.write(chunk) - controller.enqueue(textEncoder.encode(JSON.stringify(this.schemaInstance))) + bytesSinceEmission += chunk.byteLength + if (shouldEmit()) { + emitSnapshot(controller) + } } catch (error) { controller.error(error) } }, - flush: (): void => { + flush: controller => { if (!parser.isEnded) { parser.end() } + if (snapshotPolicy.mode !== "chunk" && emittedRevision !== parserRevision) { + emitSnapshot(controller) + } this.activePath = [] this.emitCompletion() } }) } + /** + * Creates a transform that emits cumulative JSON snapshots at the selected cadence. + * Omitting `snapshotPolicy` preserves the existing one-snapshot-per-input-chunk behavior. + */ + public parse(options: SchemaStreamParseOptions = {}): TransformStream { + return this.createTransform(options) + } + /** * Consumes streamed JSON text or bytes and yields immutable schema-shaped snapshots. * The completed value is still unvalidated; use the producing SDK's settled output or @@ -345,14 +408,22 @@ export class SchemaStream { source: SchemaStreamSource, options: SchemaStreamParseOptions = {} ): AsyncGenerator, void, void> { + const decoder = new TextDecoder() + const outputQueue: SchemaStreamChunk[] = [] + const transform = this.createTransform(options, snapshot => { + outputQueue.push(JSON.parse(decoder.decode(snapshot)) as SchemaStreamChunk) + }) const sourceHandle = openSource(source) - const transform = this.parse(options) const reader = transform.readable.getReader() const writer = transform.writable.getWriter() const encoder = new TextEncoder() - const decoder = new TextDecoder() let sourceDone = false let parserDone = false + const outputPump = (async () => { + while (!(await reader.read()).done) { + // Reading relieves TransformStream backpressure; createTransform owns snapshot decoding. + } + })() try { while (true) { @@ -364,18 +435,16 @@ export class SchemaStream { const input: SchemaStreamInputChunk = next.value const bytes = typeof input === "string" ? encoder.encode(input) : input - const [, output] = await Promise.all([writer.write(bytes), reader.read()]) - - if (output.done) { - throw new Error("SchemaStream parser ended before its input source") + await writer.write(bytes) + while (outputQueue.length > 0) { + yield outputQueue.shift() as SchemaStreamChunk } - - yield JSON.parse(decoder.decode(output.value)) as SchemaStreamChunk } - const [, output] = await Promise.all([writer.close(), reader.read()]) - if (!output.done) { - throw new Error("SchemaStream parser emitted an unexpected final chunk") + await writer.close() + await outputPump + while (outputQueue.length > 0) { + yield outputQueue.shift() as SchemaStreamChunk } parserDone = true } finally { @@ -385,6 +454,7 @@ export class SchemaStream { cleanup.push(writer.abort(), reader.cancel()) } + cleanup.push(outputPump.catch(() => undefined)) await Promise.allSettled(cleanup) writer.releaseLock() reader.releaseLock() diff --git a/tests/iterate.test.ts b/tests/iterate.test.ts index 0c59fb2..77c3b5c 100644 --- a/tests/iterate.test.ts +++ b/tests/iterate.test.ts @@ -1,4 +1,4 @@ -import { SchemaStream, type SchemaStreamChunk } from "@/index" +import { SchemaStream, type SchemaStreamChunk, type SnapshotPolicy } from "@/index" import { describe, expect, test } from "bun:test" import * as z from "zod" @@ -13,6 +13,43 @@ async function collect( } describe("SchemaStream.iterate", () => { + test("applies opt-in snapshot policies without changing the default", async () => { + const schema = z.object({ first: z.string(), second: z.number() }) + const expected = { first: "streaming", second: 2 } + const json = JSON.stringify(expected) + const policies: Array<{ expectedCount: number; snapshotPolicy?: SnapshotPolicy }> = [ + { expectedCount: json.length }, + { expectedCount: json.length, snapshotPolicy: { mode: "chunk" } }, + { expectedCount: 2, snapshotPolicy: { mode: "value" } }, + { expectedCount: Math.ceil(json.length / 8), snapshotPolicy: { bytes: 8, mode: "bytes" } }, + { expectedCount: 1, snapshotPolicy: { mode: "final" } } + ] + + for (const { expectedCount, snapshotPolicy } of policies) { + const source = (async function* () { + for (const character of json) { + yield character + } + })() + const emissions = await collect(new SchemaStream(schema).iterate(source, { snapshotPolicy })) + + expect(emissions).toHaveLength(expectedCount) + expect(emissions.at(-1)).toEqual(expected) + } + }) + + test("rejects invalid policies before locking readable sources", async () => { + const source = new ReadableStream() + const iterator = new SchemaStream(z.object({ value: z.string() })).iterate(source, { + snapshotPolicy: { bytes: 0, mode: "bytes" } + }) + + await expect(iterator.next()).rejects.toThrow( + "snapshotPolicy.bytes must be a positive, finite integer" + ) + expect(source.locked).toBe(false) + }) + test("consumes string ReadableStreams with progressive nested emissions", async () => { const schema = z.object({ title: z.string(), diff --git a/tests/packed-consumer/consumer-zod3.ts b/tests/packed-consumer/consumer-zod3.ts index acc593d..4ef1868 100644 --- a/tests/packed-consumer/consumer-zod3.ts +++ b/tests/packed-consumer/consumer-zod3.ts @@ -24,12 +24,12 @@ const input = new ReadableStream({ }) const emissions: unknown[] = [] -for await (const partial of parser.iterate(input)) { +for await (const partial of parser.iterate(input, { snapshotPolicy: { mode: "value" } })) { emissions.push(partial) } -if (!emissions.some(value => JSON.stringify(value).includes('"title":"hel"'))) { - throw new Error("schema-stream packed Zod 3 progressive emission mismatch") +if (emissions.length !== 2 || !JSON.stringify(emissions.at(-1)).includes('"count":2')) { + throw new Error("schema-stream packed Zod 3 value-policy emission mismatch") } console.log("packed Zod 3 types and runtime passed") diff --git a/tests/packed-consumer/consumer.ts b/tests/packed-consumer/consumer.ts index e5615e2..2255011 100644 --- a/tests/packed-consumer/consumer.ts +++ b/tests/packed-consumer/consumer.ts @@ -1,6 +1,6 @@ import { Agent, run } from "@openai/agents" import { Output, streamText } from "ai" -import { SchemaStream, type SchemaStreamChunk } from "schema-stream" +import { SchemaStream, type SchemaStreamChunk, type SnapshotPolicy } from "schema-stream" import { z } from "zod" import * as zm from "zod/mini" @@ -38,6 +38,17 @@ if (!emissions.some(partial => partial.title === "hel") || emissions.at(-1)?.nes throw new Error("schema-stream packed iterate mismatch") } +const finalPolicy = { mode: "final" } satisfies SnapshotPolicy +const finalEmissions: SchemaStreamChunk[] = [] +for await (const partial of new SchemaStream(schema).iterate(chunkedJson(), { + snapshotPolicy: finalPolicy +})) { + finalEmissions.push(partial) +} +if (finalEmissions.length !== 1 || finalEmissions[0]?.nested?.count !== 2) { + throw new Error("schema-stream packed snapshot policy mismatch") +} + const miniSchema = zm.object({ title: zm.string(), nested: zm.optional(zm.object({ count: zm.number() })) diff --git a/tests/snapshot-policy.benchmark.ts b/tests/snapshot-policy.benchmark.ts new file mode 100644 index 0000000..753b4f5 --- /dev/null +++ b/tests/snapshot-policy.benchmark.ts @@ -0,0 +1,114 @@ +import { SchemaStream, type SnapshotPolicy } from "@/index" +import * as z from "zod" + +interface BenchmarkResult { + durationSeconds: string + emittedMb: string + emittedSnapshots: number + mode: string + throughputMbPerSecond: string +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() +const networkChunkSize = 64 * 1024 +const payloadSizeMb = Number(Bun.argv[2] ?? 10) +const selectedMode = Bun.argv[3] + +if (!Number.isFinite(payloadSizeMb) || payloadSizeMb <= 0) { + throw new TypeError("Payload size must be a positive number of megabytes") +} + +const schema = z.object({ + content: z.string(), + records: z.array(z.object({ active: z.boolean(), id: z.number() })) +}) +const expected = { + content: "x".repeat(payloadSizeMb * 1024 * 1024), + records: Array.from({ length: 10_000 }, (_, id) => ({ active: id % 2 === 0, id })) +} +const encodedJson = encoder.encode(JSON.stringify(expected)) +const policies: Array<{ mode: string; snapshotPolicy: SnapshotPolicy }> = [ + { mode: "chunk", snapshotPolicy: { mode: "chunk" } }, + { mode: "value", snapshotPolicy: { mode: "value" } }, + { mode: "bytes-256kb", snapshotPolicy: { bytes: 256 * 1024, mode: "bytes" } }, + { mode: "bytes-1mb", snapshotPolicy: { bytes: 1024 * 1024, mode: "bytes" } }, + { mode: "final", snapshotPolicy: { mode: "final" } } +] + +async function runPolicy({ + mode, + snapshotPolicy +}: { + mode: string + snapshotPolicy: SnapshotPolicy +}): Promise { + const parser = new SchemaStream(schema) + const transform = parser.parse({ snapshotPolicy }) + const writer = transform.writable.getWriter() + const reader = transform.readable.getReader() + const startedAt = performance.now() + let emittedBytes = 0 + let emittedSnapshots = 0 + let finalSnapshot: Uint8Array | undefined + const readPromise = (async () => { + while (true) { + const output = await reader.read() + if (output.done) return + emittedBytes += output.value.byteLength + emittedSnapshots += 1 + finalSnapshot = output.value + } + })() + + for (let offset = 0; offset < encodedJson.length; offset += networkChunkSize) { + await writer.write(encodedJson.slice(offset, offset + networkChunkSize)) + } + await writer.close() + await readPromise + + if (!finalSnapshot) { + throw new Error(`${mode} did not emit a final snapshot`) + } + const actual = JSON.parse(decoder.decode(finalSnapshot)) + if (actual.content !== expected.content || actual.records.length !== expected.records.length) { + throw new Error(`${mode} emitted an incorrect final snapshot`) + } + + const durationSeconds = (performance.now() - startedAt) / 1000 + return { + durationSeconds: durationSeconds.toFixed(3), + emittedMb: (emittedBytes / 1024 / 1024).toFixed(2), + emittedSnapshots, + mode, + throughputMbPerSecond: (encodedJson.byteLength / 1024 / 1024 / durationSeconds).toFixed(2) + } +} + +if (selectedMode) { + const policy = policies.find(({ mode }) => mode === selectedMode) + if (!policy) { + throw new TypeError(`Unknown snapshot policy benchmark: ${selectedMode}`) + } + console.log(JSON.stringify(await runPolicy(policy))) +} else { + const results: BenchmarkResult[] = [] + for (const policy of policies) { + const process = Bun.spawn( + [Bun.argv[0] as string, import.meta.path, String(payloadSizeMb), policy.mode], + { stderr: "inherit", stdout: "pipe" } + ) + const output = await new Response(process.stdout).text() + const exitCode = await process.exited + if (exitCode !== 0) { + throw new Error(`${policy.mode} benchmark exited with code ${exitCode}`) + } + results.push(JSON.parse(output) as BenchmarkResult) + } + + console.log({ + networkChunkKb: networkChunkSize / 1024, + payloadMb: (encodedJson.byteLength / 1024 / 1024).toFixed(2) + }) + console.table(results) +} diff --git a/tests/snapshot-policy.test.ts b/tests/snapshot-policy.test.ts new file mode 100644 index 0000000..0b31895 --- /dev/null +++ b/tests/snapshot-policy.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test" +import * as z from "zod" + +import { SchemaStream, type SchemaStreamParseOptions, type SnapshotPolicy } from "@/index" + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +async function collectSnapshots({ + chunks, + options +}: { + chunks: Uint8Array[] + options?: SchemaStreamParseOptions +}): Promise { + const schema = z.object({ first: z.string(), second: z.number() }) + const transform = new SchemaStream(schema).parse(options) + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk) + } + controller.close() + } + }) + const snapshots: unknown[] = [] + + for await (const output of source.pipeThrough(transform)) { + snapshots.push(JSON.parse(decoder.decode(output))) + } + return snapshots +} + +function splitBytes(json: string, chunkSize: number): Uint8Array[] { + const encoded = encoder.encode(json) + const chunks: Uint8Array[] = [] + for (let offset = 0; offset < encoded.length; offset += chunkSize) { + chunks.push(encoded.slice(offset, offset + chunkSize)) + } + return chunks +} + +describe("snapshot policies", () => { + test("keeps omitted policy identical to explicit chunk behavior", async () => { + const chunks = splitBytes(JSON.stringify({ first: "same", second: 2 }), 3) + + const implicit = await collectSnapshots({ chunks }) + const explicit = await collectSnapshots({ + chunks, + options: { snapshotPolicy: { mode: "chunk" } } + }) + + expect(explicit).toEqual(implicit) + expect(explicit).toHaveLength(chunks.length) + }) + + test("emits at completed-value boundaries", async () => { + const json = JSON.stringify({ first: "streaming", second: 2 }) + + const snapshots = await collectSnapshots({ + chunks: splitBytes(json, 1), + options: { snapshotPolicy: { mode: "value" } } + }) + + expect(snapshots).toEqual([ + { first: "streaming", second: null }, + { first: "streaming", second: 2 } + ]) + }) + + test("coalesces multiple completed values in one input chunk", async () => { + const json = JSON.stringify({ first: "complete", second: 2 }) + + const snapshots = await collectSnapshots({ + chunks: [encoder.encode(json)], + options: { snapshotPolicy: { mode: "value" } } + }) + + expect(snapshots).toEqual([{ first: "complete", second: 2 }]) + }) + + test("handles byte thresholds, overshoot, and a smaller final tail", async () => { + const json = JSON.stringify({ first: "threshold", second: 2 }) + const chunks = splitBytes(json, 5) + + const snapshots = await collectSnapshots({ + chunks, + options: { snapshotPolicy: { bytes: 12, mode: "bytes" } } + }) + + expect(snapshots).toHaveLength(Math.ceil(chunks.length / 3)) + expect(snapshots.at(-1)).toEqual({ first: "threshold", second: 2 }) + }) + + test("emits only the final parser state in final mode", async () => { + const json = JSON.stringify({ first: "final", second: 2 }) + + const snapshots = await collectSnapshots({ + chunks: splitBytes(json, 1), + options: { snapshotPolicy: { mode: "final" } } + }) + + expect(snapshots).toEqual([{ first: "final", second: 2 }]) + }) + + test("emits empty containers in every mode", async () => { + const schema = z.object({ items: z.array(z.string()), metadata: z.object({}) }) + const json = encoder.encode('{"items":[],"metadata":{}}') + const policies: SnapshotPolicy[] = [ + { mode: "chunk" }, + { mode: "value" }, + { bytes: 1024, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + const outputs = new ReadableStream({ + start(controller) { + controller.enqueue(json) + controller.close() + } + }).pipeThrough(new SchemaStream(schema).parse({ snapshotPolicy })) + const snapshots: unknown[] = [] + for await (const output of outputs) { + snapshots.push(JSON.parse(decoder.decode(output))) + } + expect(snapshots.at(-1)).toEqual({ items: [], metadata: {} }) + } + }) + + test("rejects invalid byte thresholds synchronously", () => { + const parser = new SchemaStream(z.object({ value: z.string() })) + + for (const bytes of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => parser.parse({ snapshotPolicy: { bytes, mode: "bytes" } })).toThrow( + "snapshotPolicy.bytes must be a positive, finite integer" + ) + } + }) + + test("propagates malformed and truncated JSON in every mode", async () => { + const policies: SnapshotPolicy[] = [ + { mode: "chunk" }, + { mode: "value" }, + { bytes: 8, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + await expect( + collectSnapshots({ + chunks: [encoder.encode('{"first": nope}')], + options: { snapshotPolicy } + }) + ).rejects.toBeInstanceOf(Error) + await expect( + collectSnapshots({ + chunks: [encoder.encode('{"first":"unfinished')], + options: { snapshotPolicy } + }) + ).rejects.toBeInstanceOf(Error) + } + }) + + test("rejects trailing content in every mode", async () => { + const policies: SnapshotPolicy[] = [ + { mode: "chunk" }, + { mode: "value" }, + { bytes: 8, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + await expect( + collectSnapshots({ + chunks: [encoder.encode('{"first":"done","second":2}'), encoder.encode("garbage")], + options: { snapshotPolicy } + }) + ).rejects.toThrow('Unexpected "g"') + } + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..1c25464 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "noEmit": false, + "outDir": "dist", + "rewriteRelativeImportExtensions": true, + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["coverage", "dist", "node_modules", "tests"] +} diff --git a/tsconfig.json b/tsconfig.json index e201794..e06b97a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,5 @@ { "compilerOptions": { - "baseUrl": ".", "declaration": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/tsup.config.ts b/tsup.config.ts index 13b4ee1..b25b1bd 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -2,8 +2,9 @@ import { defineConfig } from "tsup" export default defineConfig(options => { return { + clean: !options.watch, entry: ["src/index.ts"], - dts: true, + dts: false, watch: options.watch, sourcemap: true, minify: true, From 980b3cda3322102b241774a3c83920f7c7b70871 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 10 Jul 2026 19:18:27 -0400 Subject: [PATCH 2/5] fix: harden unicode parsing and performance --- docs/snapshot-policies.md | 8 +-- package.json | 2 + src/utils/buffered-string.ts | 29 +++++++++- src/utils/streaming-json-parser.ts | 87 ++++++++++++++++++++++++++---- src/utils/token-parser.ts | 24 ++++----- src/utils/tokenizer.ts | 85 ++++++++++++++++------------- src/utils/zod-compat.ts | 8 +++ tests/packed-consumer/verify.sh | 5 ++ tests/parser-regressions.test.ts | 33 ++++++++++++ tests/snapshot-policy.test.ts | 50 +++++++++++++++++ tests/zod-compatibility.test.ts | 8 +++ 11 files changed, 273 insertions(+), 66 deletions(-) diff --git a/docs/snapshot-policies.md b/docs/snapshot-policies.md index 707e1f2..e733c74 100644 --- a/docs/snapshot-policies.md +++ b/docs/snapshot-policies.md @@ -49,7 +49,8 @@ characters do not repeatedly serialize the entire accumulated object. Emits when source bytes received since the previous snapshot meet or exceed the positive integer threshold. Thresholds are evaluated at source-chunk boundaries; SchemaStream does not split input -chunks. A final tail smaller than the threshold is still emitted. +chunks. At flush, parser state not represented by the previous snapshot is emitted below the +threshold; structural-only trailing bytes do not create a duplicate snapshot. ### `final` @@ -65,7 +66,8 @@ validated output from the producing SDK. ## Errors, callbacks, and backpressure -- Invalid byte thresholds throw synchronously. +- `parse()` rejects invalid byte thresholds synchronously. `iterate()` rejects on first advancement, + before locking the source. - Malformed and truncated JSON reject under every policy. - `onKeyComplete` cadence is independent of snapshot cadence. - `iterate()` preserves source backpressure at emission boundaries and cancels its source when the @@ -80,7 +82,7 @@ validated output from the producing SDK. Run the policy comparison with an optional payload size in megabytes: ```bash -bun tests/snapshot-policy.benchmark.ts 25 +bun run benchmark:snapshots 25 ``` The benchmark combines one long string with 10,000 nested records and reports source throughput, diff --git a/package.json b/package.json index 6f779d3..b94189f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ }, "files": [ "dist/**", + "docs/snapshot-policies.md", "CHANGELOG.md", "LICENSE", "README.md" @@ -49,6 +50,7 @@ }, "packageManager": "bun@1.3.14", "scripts": { + "benchmark:snapshots": "bun tests/snapshot-policy.benchmark.ts", "build": "tsup && tsc --project tsconfig.build.json", "check": "bun run format:check && bun run type-check && bun run test && bun run test:packed", "changeset": "changeset", diff --git a/src/utils/buffered-string.ts b/src/utils/buffered-string.ts index 2a8250e..37d6d22 100644 --- a/src/utils/buffered-string.ts +++ b/src/utils/buffered-string.ts @@ -12,12 +12,14 @@ export interface StringBuilder { byteLength: number appendChar: (char: number) => void appendBuf: (buf: Uint8Array, start?: number, end?: number) => void + appendString: (value: string) => void reset: () => void toString: () => string } export class NonBufferedString implements StringBuilder { private decoder = new TextDecoder("utf-8") + private encoder = new TextEncoder() private string = "" private onIncrementalString?: (str: string) => void @@ -39,8 +41,14 @@ export class NonBufferedString implements StringBuilder { this.update() } + public appendString(value: string): void { + this.string += value + this.byteLength += this.encoder.encode(value).byteLength + this.update() + } + private update(): void { - if (this.onIncrementalString) this.onIncrementalString(this.toString()) + if (this.onIncrementalString) this.onIncrementalString(this.string) } public reset(): void { @@ -55,6 +63,7 @@ export class NonBufferedString implements StringBuilder { export class BufferedString implements StringBuilder { private decoder = new TextDecoder("utf-8") + private encoder = new TextEncoder() private buffer: Uint8Array private bufferOffset = 0 private string = "" @@ -77,19 +86,35 @@ export class BufferedString implements StringBuilder { const size = end - start if (this.bufferOffset + size > this.buffer.length) this.flushStringBuffer() + if (size > this.buffer.length) { + this.string += this.decoder.decode(buf.subarray(start, end)) + this.byteLength += size + this.update() + return + } + this.buffer.set(buf.subarray(start, end), this.bufferOffset) this.bufferOffset += size this.byteLength += size } + public appendString(value: string): void { + this.flushStringBuffer() + this.string += value + this.byteLength += this.encoder.encode(value).byteLength + this.update() + } + private flushStringBuffer(): void { + if (this.bufferOffset === 0) return + this.string += this.decoder.decode(this.buffer.subarray(0, this.bufferOffset)) this.bufferOffset = 0 this.update() } private update(): void { - if (this.onIncrementalString) this.onIncrementalString(this.toString()) + if (this.onIncrementalString) this.onIncrementalString(this.string) } public reset(): void { diff --git a/src/utils/streaming-json-parser.ts b/src/utils/streaming-json-parser.ts index f9f2ce9..ec85a25 100644 --- a/src/utils/streaming-json-parser.ts +++ b/src/utils/streaming-json-parser.ts @@ -14,39 +14,71 @@ import { type ZodSchema } from "./zod-compat" +/** Primitive placeholders used until streamed JSON supplies a value. */ export type TypeDefaults = { string?: string | null | undefined number?: number | null | undefined boolean?: boolean | null | undefined } +/** Object keys and array indexes locating a value in the streamed document. */ export type SchemaPath = (string | number | undefined)[] +/** Completion state reported after a streamed value changes. */ export type OnKeyCompleteCallbackParams = { + /** The value currently receiving streamed content, or an empty path after parsing finishes. */ activePath: SchemaPath + /** Unique value paths that have completed at least once, in completion order. */ completedPaths: SchemaPath[] } +/** Receives immutable path snapshots as streamed values progress and complete. */ export type OnKeyCompleteCallback = (data: OnKeyCompleteCallbackParams) => void +/** Configures schema-derived placeholders and completion reporting. */ export type SchemaStreamOptions = { + /** Field-level placeholders that take precedence over schema and primitive defaults. */ defaultData?: SchemaStreamDefaultData + /** Fallback placeholders for primitive schema nodes. Defaults to `null`. */ typeDefaults?: TypeDefaults + /** Called as values progress and once more with an empty active path after completion. */ onKeyComplete?: OnKeyCompleteCallback } +/** Configures JSON tokenization and snapshot cadence for `parse()` and `iterate()`. */ export type SchemaStreamParseOptions = { + /** Buffers string bytes in fixed-size blocks instead of emitting every incremental string. */ stringBufferSize?: number + /** Converts unescaped newlines inside strings to `\n`; enabled by default. */ handleUnescapedNewLines?: boolean + /** Selects when cumulative schema-shaped snapshots are emitted. */ snapshotPolicy?: SnapshotPolicy } -/** Controls when cumulative JSON snapshots are serialized and emitted. */ +/** Controls when cumulative JSON snapshots are serialized and emitted. The default is `chunk`. */ export type SnapshotPolicy = - { mode: "chunk" } | { mode: "value" } | { mode: "bytes"; bytes: number } | { mode: "final" } + | { + /** Emits after every input chunk. */ + mode: "chunk" + } + | { + /** Emits when an input chunk completes one or more primitive values. */ + mode: "value" + } + | { + /** Emits after this many or more source bytes have arrived. */ + bytes: number + mode: "bytes" + } + | { + /** Emits one snapshot after the complete JSON document is parsed. */ + mode: "final" + } +/** A text or UTF-8 byte chunk accepted by `iterate()`. */ export type SchemaStreamInputChunk = string | Uint8Array +/** A Web Stream or async iterable that supplies JSON text or UTF-8 bytes. */ export type SchemaStreamSource = ReadableStream | AsyncIterable @@ -106,8 +138,14 @@ function setPathValue(target: Record, path: SchemaPath, value: } } -function pathsEqual(left: SchemaPath, right: SchemaPath): boolean { - return left.length === right.length && left.every((segment, index) => segment === right[index]) +function getPathKey(path: SchemaPath): string { + return path + .map(segment => { + if (segment === undefined) return "u" + if (typeof segment === "number") return `n:${segment}` + return `s:${segment.length}:${segment}` + }) + .join("|") } function openSource( @@ -160,14 +198,23 @@ function openSource( /** * Parses chunked JSON into schema-shaped intermediate values. SchemaStream does not * validate chunks; consumers should validate the final value with their Zod schema. + * + * @typeParam TSchema - Object schema that determines placeholders and snapshot inference. */ export class SchemaStream { private schemaInstance: Record private activePath: SchemaPath = [] private completedPaths: SchemaPath[] = [] + private completedPathKeys = new Set() private readonly onKeyComplete?: OnKeyCompleteCallback private readonly typeDefaults?: TypeDefaults + /** + * Creates parser state and schema-derived placeholders for one streamed JSON document. + * + * @param schema - Zod 3, Zod 4, or Zod Mini object schema used for types and placeholders. + * @param options - Placeholder defaults and completion reporting. + */ public constructor(schema: TSchema, options: SchemaStreamOptions = {}) { this.typeDefaults = options.typeDefaults this.schemaInstance = this.createBlankObject( @@ -287,12 +334,12 @@ export class SchemaStream { const valuePath = this.getPathFromStack(stack, key) this.activePath = valuePath - if ( - !partial && - valuePath.length > 0 && - !this.completedPaths.some(completedPath => pathsEqual(completedPath, valuePath)) - ) { - this.completedPaths.push([...valuePath]) + if (!partial && valuePath.length > 0) { + const pathKey = getPathKey(valuePath) + if (!this.completedPathKeys.has(pathKey)) { + this.completedPathKeys.add(pathKey) + this.completedPaths.push([...valuePath]) + } } setPathValue(this.schemaInstance, valuePath, value) @@ -301,7 +348,14 @@ export class SchemaStream { return !partial && valuePath.length > 0 } - /** Returns a new schema-derived stub using this instance's primitive defaults. */ + /** + * Returns a new schema-derived stub using this instance's primitive defaults. + * + * @typeParam TStubSchema - Object schema whose input type determines the returned stub. + * @param schema - Schema used to derive nested placeholders. + * @param defaultData - Field-level placeholders that override derived defaults. + * @returns A new partial, schema-shaped value that is independent of parser state. + */ public getSchemaStub( schema: TStubSchema, defaultData?: SchemaStreamDefaultData @@ -394,6 +448,10 @@ export class SchemaStream { /** * Creates a transform that emits cumulative JSON snapshots at the selected cadence. * Omitting `snapshotPolicy` preserves the existing one-snapshot-per-input-chunk behavior. + * + * @param options - Tokenizer behavior and snapshot cadence. + * @returns A byte transform whose outputs are serialized schema-shaped snapshots. + * @throws {TypeError} When a byte snapshot threshold is not a positive finite integer. */ public parse(options: SchemaStreamParseOptions = {}): TransformStream { return this.createTransform(options) @@ -403,6 +461,13 @@ export class SchemaStream { * Consumes streamed JSON text or bytes and yields immutable schema-shaped snapshots. * The completed value is still unvalidated; use the producing SDK's settled output or * validate the final snapshot with the schema. + * + * @typeParam TChunk - Source chunk type, inferred from the stream or async iterable. + * @param source - JSON text or UTF-8 bytes supplied with source backpressure. + * @param options - Tokenizer behavior and snapshot cadence. + * @returns An async generator of independent schema-shaped values. + * @throws {TypeError} When the source or byte snapshot threshold is invalid. + * @throws {Error} When the source fails or the JSON is malformed or truncated. */ public async *iterate( source: SchemaStreamSource, diff --git a/src/utils/token-parser.ts b/src/utils/token-parser.ts index 9e66c91..5b11ca9 100644 --- a/src/utils/token-parser.ts +++ b/src/utils/token-parser.ts @@ -52,7 +52,7 @@ export const enum TokenParserState { SEPARATOR } -function TokenParserStateToString(state: TokenParserState): string { +function tokenParserStateToString(state: TokenParserState): string { return ["VALUE", "KEY", "COLON", "COMMA", "ENDED", "ERROR", "SEPARATOR"][state] } @@ -71,8 +71,7 @@ const defaultOpts: TokenParserOptions = { export class TokenParserError extends Error { constructor(message: string) { super(message) - // Typescript is broken. This is a workaround - Object.setPrototypeOf(this, TokenParserError.prototype) + Object.setPrototypeOf(this, new.target.prototype) } } @@ -102,7 +101,7 @@ export default class TokenParser { }) } - this.keepStack = true + this.keepStack = opts.keepStack ?? true this.separator = opts.separator } @@ -153,8 +152,11 @@ export default class TokenParser { private emit(value: JsonPrimitive | JsonStruct, emit: boolean): void { if (!this.keepStack && this.value && this.stack.every(item => !item.emit)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (this.value as JsonStruct as any)[this.key as string | number] + if (Array.isArray(this.value) && typeof this.key === "number") { + delete this.value[this.key] + } else if (!Array.isArray(this.value) && typeof this.key === "string") { + delete this.value[this.key] + } } if (emit) { @@ -303,11 +305,10 @@ export default class TokenParser { throw new TokenParserError( `Unexpected ${TokenType[token]} (${JSON.stringify( value - )}) in state ${TokenParserStateToString(this.state)}` + )}) in state ${tokenParserStateToString(this.state)}` ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - this.error(err) + } catch (error: unknown) { + this.error(error instanceof Error ? error : new Error(String(error))) } } @@ -326,7 +327,7 @@ export default class TokenParser { ) { this.error( new Error( - `Parser ended in mid-parsing (state: ${TokenParserStateToString( + `Parser ended in mid-parsing (state: ${tokenParserStateToString( this.state )}). Either not all the data was received or the data was invalid.` ) @@ -337,7 +338,6 @@ export default class TokenParser { } } - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ public onValue(_parsedElementInfo: ParsedElementInfo): void { throw new TokenParserError('Can\'t emit data before the "onValue" callback has been set up.') } diff --git a/src/utils/tokenizer.ts b/src/utils/tokenizer.ts index 565eec4..00a0e51 100644 --- a/src/utils/tokenizer.ts +++ b/src/utils/tokenizer.ts @@ -46,7 +46,7 @@ const enum TokenizerStates { SEPARATOR } -function TokenizerStateToString(tokenizerState: TokenizerStates): string { +function tokenizerStateToString(tokenizerState: TokenizerStates): string { return [ "START", "ENDED", @@ -97,8 +97,7 @@ const defaultOpts: TokenizerOptions = { export class TokenizerError extends Error { constructor(message: string) { super(message) - // Typescript is broken. This is a workaround - Object.setPrototypeOf(this, TokenizerError.prototype) + Object.setPrototypeOf(this, new.target.prototype) } } @@ -114,12 +113,19 @@ export default class Tokenizer { private unicode?: string // unicode escapes private highSurrogate?: number - private bytes_remaining = 0 // number of bytes remaining in multi byte utf8 char to read after split boundary - private bytes_in_sequence = 0 // bytes in multi byte utf8 char to read - private char_split_buffer = new Uint8Array(4) // for rebuilding chars split before boundary is reached + private bytesRemaining = 0 + private bytesInSequence = 0 + private charSplitBuffer = new Uint8Array(4) private encoder = new TextEncoder() private offset = -1 + private appendPendingHighSurrogate(): void { + if (this.highSurrogate === undefined) return + + this.bufferedString.appendString(String.fromCharCode(this.highSurrogate)) + this.highSurrogate = undefined + } + constructor(opts?: TokenizerOptions) { opts = { ...defaultOpts, ...opts } @@ -291,6 +297,13 @@ export default class Tokenizer { break // STRING case TokenizerStates.STRING_DEFAULT: + if (n === charset.REVERSE_SOLIDUS) { + this.state = TokenizerStates.STRING_AFTER_BACKSLASH + continue + } + + this.appendPendingHighSurrogate() + if (this.handleUnescapedNewLines && n === charset.NEWLINE) { this.bufferedString.appendChar(charset.REVERSE_SOLIDUS) // Appends '\' this.bufferedString.appendChar(charset.LATIN_SMALL_LETTER_N) // Appends 'n' @@ -309,30 +322,25 @@ export default class Tokenizer { continue } - if (n === charset.REVERSE_SOLIDUS) { - this.state = TokenizerStates.STRING_AFTER_BACKSLASH - continue - } - if (n >= 128) { // Parse multi byte (>=128) chars one at a time if (n >= 194 && n <= 223) { - this.bytes_in_sequence = 2 + this.bytesInSequence = 2 } else if (n <= 239) { - this.bytes_in_sequence = 3 + this.bytesInSequence = 3 } else { - this.bytes_in_sequence = 4 + this.bytesInSequence = 4 } - if (this.bytes_in_sequence <= buffer.length - i) { + if (this.bytesInSequence <= buffer.length - i) { // if bytes needed to complete char fall outside buffer length, we have a boundary split - this.bufferedString.appendBuf(buffer, i, i + this.bytes_in_sequence) - i += this.bytes_in_sequence - 1 + this.bufferedString.appendBuf(buffer, i, i + this.bytesInSequence) + i += this.bytesInSequence - 1 continue } - this.bytes_remaining = i + this.bytes_in_sequence - buffer.length - this.char_split_buffer.set(buffer.subarray(i)) + this.bytesRemaining = i + this.bytesInSequence - buffer.length + this.charSplitBuffer.set(buffer.subarray(i)) i = buffer.length - 1 this.state = TokenizerStates.STRING_INCOMPLETE_CHAR continue @@ -346,14 +354,14 @@ export default class Tokenizer { break case TokenizerStates.STRING_INCOMPLETE_CHAR: { // Carry a multibyte character across as many input chunks as needed. - const availableBytes = Math.min(this.bytes_remaining, buffer.length - i) - const targetOffset = this.bytes_in_sequence - this.bytes_remaining - this.char_split_buffer.set(buffer.subarray(i, i + availableBytes), targetOffset) - this.bytes_remaining -= availableBytes + const availableBytes = Math.min(this.bytesRemaining, buffer.length - i) + const targetOffset = this.bytesInSequence - this.bytesRemaining + this.charSplitBuffer.set(buffer.subarray(i, i + availableBytes), targetOffset) + this.bytesRemaining -= availableBytes i += availableBytes - 1 - if (this.bytes_remaining === 0) { - this.bufferedString.appendBuf(this.char_split_buffer, 0, this.bytes_in_sequence) + if (this.bytesRemaining === 0) { + this.bufferedString.appendBuf(this.charSplitBuffer, 0, this.bytesInSequence) this.state = TokenizerStates.STRING_DEFAULT } @@ -361,6 +369,7 @@ export default class Tokenizer { } case TokenizerStates.STRING_AFTER_BACKSLASH: if (escapedSequences?.[n]) { + this.appendPendingHighSurrogate() this.bufferedString.appendChar(escapedSequences[n]) this.state = TokenizerStates.STRING_DEFAULT continue @@ -398,20 +407,21 @@ export default class Tokenizer { //<55296,56319> - highSurrogate this.highSurrogate = intVal } else { - this.bufferedString.appendBuf(this.encoder.encode(String.fromCharCode(intVal))) + this.bufferedString.appendString(String.fromCharCode(intVal)) } } else { if (intVal >= 0xdc00 && intVal <= 0xdfff) { //<56320,57343> - lowSurrogate - this.bufferedString.appendBuf( - this.encoder.encode(String.fromCharCode(this.highSurrogate, intVal)) - ) + this.bufferedString.appendString(String.fromCharCode(this.highSurrogate, intVal)) + this.highSurrogate = undefined } else { - this.bufferedString.appendBuf( - this.encoder.encode(String.fromCharCode(this.highSurrogate)) - ) + this.appendPendingHighSurrogate() + if (intVal >= 0xd800 && intVal <= 0xdbff) { + this.highSurrogate = intVal + } else { + this.bufferedString.appendString(String.fromCharCode(intVal)) + } } - this.highSurrogate = undefined } this.state = TokenizerStates.STRING_DEFAULT continue @@ -638,12 +648,11 @@ export default class Tokenizer { throw new TokenizerError( `Unexpected "${String.fromCharCode( n - )}" at position "${i}" in state ${TokenizerStateToString(this.state)}` + )}" at position "${i}" in state ${tokenizerStateToString(this.state)}` ) } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - this.error(err) + } catch (error: unknown) { + this.error(error instanceof Error ? error : new Error(String(error))) } } @@ -687,7 +696,7 @@ export default class Tokenizer { default: this.error( new TokenizerError( - `Tokenizer ended in the middle of a token (state: ${TokenizerStateToString( + `Tokenizer ended in the middle of a token (state: ${tokenizerStateToString( this.state )}). Either not all the data was received or the data was invalid.` ) diff --git a/src/utils/zod-compat.ts b/src/utils/zod-compat.ts index 8480e7e..1d276c2 100644 --- a/src/utils/zod-compat.ts +++ b/src/utils/zod-compat.ts @@ -1,18 +1,23 @@ import type * as z4 from "zod/v4/core" +/** Minimal structural contract used to support a Zod 3 schema without importing its runtime. */ export type Zod3Schema = { readonly _def: unknown readonly _input: unknown readonly _output: unknown } +/** Zod 3 object schema contract required for shape inspection and type inference. */ export type Zod3ObjectSchema = Zod3Schema & { readonly shape: Readonly> } +/** Schema versions accepted by SchemaStream's compatibility layer. */ export type ZodSchema = Zod3Schema | z4.$ZodType +/** Object schemas accepted by the public `SchemaStream` constructor. */ export type ZodObjectSchema = Zod3ObjectSchema | z4.$ZodObject +/** Infers the input value represented by a supported Zod schema. */ export type SchemaInput = TSchema extends { _zod: z4.$ZodType["_zod"] } @@ -21,6 +26,7 @@ export type SchemaInput = TSchema extends { ? TSchema["_input"] : never +/** Recursively makes streamed fields optional and primitive values nullable. */ export type SchemaStreamValue = unknown extends TValue ? unknown : TValue extends readonly (infer TItem)[] @@ -29,10 +35,12 @@ export type SchemaStreamValue = unknown extends TValue ? { [TKey in keyof TValue]?: SchemaStreamValue } : TValue | null +/** Progressive, schema-shaped value yielded for a supported object schema. */ export type SchemaStreamChunk = SchemaStreamValue< SchemaInput > +/** Field-level placeholder overrides accepted when creating schema-derived stubs. */ export type SchemaStreamDefaultData = Partial< SchemaStreamChunk > diff --git a/tests/packed-consumer/verify.sh b/tests/packed-consumer/verify.sh index 9f396fb..3eef71a 100755 --- a/tests/packed-consumer/verify.sh +++ b/tests/packed-consumer/verify.sh @@ -16,6 +16,11 @@ if [[ -z "$TARBALL" ]]; then exit 1 fi +if [[ "$(tar -tzf "$TARBALL")" != *"package/docs/snapshot-policies.md"* ]]; then + echo "schema-stream tarball is missing snapshot policy documentation" >&2 + exit 1 +fi + cp "$FIXTURE/consumer.ts" "$FIXTURE/consumer.cjs" "$FIXTURE/tsconfig.json" "$TEMP_DIR/zod4-consumer/" cd "$TEMP_DIR/zod4-consumer" npm init -y >/dev/null diff --git a/tests/parser-regressions.test.ts b/tests/parser-regressions.test.ts index 955ce17..d6bc5d2 100644 --- a/tests/parser-regressions.test.ts +++ b/tests/parser-regressions.test.ts @@ -46,6 +46,39 @@ describe("stream parser regressions", () => { expect(emissions.at(-1)).toEqual(data) }) + test("buffers multi-byte strings when the buffer is smaller than a UTF-8 character", async () => { + const schema = z.object({ text: z.string() }) + const data = { text: "šŸŒŠę—„ęœ¬čŖž" } + + for (const stringBufferSize of [1, 2, 3]) { + const { emissions } = await collectEmissions({ + schema, + chunks: [JSON.stringify(data)], + parseOptions: { stringBufferSize } + }) + + expect(emissions).toEqual([data]) + } + }) + + test("preserves escaped lone, repeated, and paired UTF-16 surrogates", async () => { + const schema = z.object({ text: z.string() }) + const highSurrogate = String.fromCharCode(0xd83d) + const data = { + text: `${highSurrogate}X|${highSurrogate}${highSurrogate}|🌊|${highSurrogate}\n` + } + const json = String.raw`{"text":"\ud83dX|\ud83d\ud83d|\ud83c\udf0a|\ud83d\n"}` + for (const stringBufferSize of [0, 1]) { + const { emissions } = await collectEmissions({ + schema, + chunks: [json], + parseOptions: { stringBufferSize } + }) + + expect(emissions).toEqual([data]) + } + }) + test("preserves large progressively parsed strings", async () => { const schema = z.object({ text: z.string() }) const data = { text: "a".repeat(64 * 1024) } diff --git a/tests/snapshot-policy.test.ts b/tests/snapshot-policy.test.ts index 0b31895..01c8535 100644 --- a/tests/snapshot-policy.test.ts +++ b/tests/snapshot-policy.test.ts @@ -179,4 +179,54 @@ describe("snapshot policies", () => { ).rejects.toThrow('Unexpected "g"') } }) + + test("parses every byte split, including UTF-8 boundaries, under every policy", async () => { + const expected = { first: 'hĆ©llo 🌊 "quoted" \\ escaped', second: -1250.5 } + const encoded = encoder.encode(JSON.stringify(expected)) + const policies: SnapshotPolicy[] = [ + { mode: "chunk" }, + { mode: "value" }, + { bytes: 7, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + for (let split = 1; split < encoded.length; split += 1) { + const snapshots = await collectSnapshots({ + chunks: [encoded.slice(0, split), encoded.slice(split)], + options: { snapshotPolicy } + }) + + expect(snapshots.at(-1)).toEqual(expected) + } + } + }) + + test("parses multi-megabyte JSON with one final snapshot", async () => { + const schema = z.object({ + content: z.string(), + records: z.array(z.object({ id: z.number(), active: z.boolean() })) + }) + const expected = { + content: "x".repeat(2 * 1024 * 1024), + records: Array.from({ length: 1_000 }, (_, id) => ({ id, active: id % 2 === 0 })) + } + const chunks = splitBytes(JSON.stringify(expected), 64 * 1024) + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk) + controller.close() + } + }) + const outputs = source.pipeThrough( + new SchemaStream(schema).parse({ snapshotPolicy: { mode: "final" } }) + ) + const snapshots: unknown[] = [] + + for await (const output of outputs) { + snapshots.push(JSON.parse(decoder.decode(output))) + } + + expect(snapshots).toEqual([expected]) + }) }) diff --git a/tests/zod-compatibility.test.ts b/tests/zod-compatibility.test.ts index 61fa5f8..c399799 100644 --- a/tests/zod-compatibility.test.ts +++ b/tests/zod-compatibility.test.ts @@ -13,6 +13,8 @@ describe("schema-derived stubs", () => { count: z4.number(), active: z4.boolean(), state: z4.enum(["pending", "ready"]), + ambiguous: z4.union([z4.string(), z4.number()]), + nullable: z4.string().nullable(), nested: z4 .object({ label: z4.string(), @@ -46,6 +48,8 @@ describe("schema-derived stubs", () => { count: 0, active: false, state: null, + ambiguous: null, + nullable: null, nested: { label: "loading", enabled: false }, tags: [], properties: {}, @@ -61,6 +65,8 @@ describe("schema-derived stubs", () => { count: z3.number(), active: z3.boolean(), state: z3.enum(["pending", "ready"]), + ambiguous: z3.union([z3.string(), z3.number()]), + nullable: z3.string().nullable(), nested: z3 .object({ label: z3.string(), @@ -81,6 +87,8 @@ describe("schema-derived stubs", () => { count: -1, active: true, state: null, + ambiguous: null, + nullable: null, nested: { label: "loading", enabled: true }, tags: [], properties: {}, From cbfea4aeda1e833d3a044a09d1a8d37ce3937b08 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 10 Jul 2026 19:42:17 -0400 Subject: [PATCH 3/5] ci: harden release workflows --- .github/workflows/ci.yml | 16 ++++++---- .github/workflows/publish.yml | 10 +++--- .github/workflows/release-pr.yml | 8 ++--- tests/workflow-security.test.ts | 54 ++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 tests/workflow-security.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42fa6b1..9dfd146 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,15 +16,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 @@ -59,15 +61,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e6523b6..29036e8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,24 +20,26 @@ jobs: runs-on: ubuntu-latest environment: PUBLISH permissions: - contents: write + contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 + persist-credentials: false - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 registry-url: https://registry.npmjs.org + package-manager-cache: false - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index ade5f2f..25a7a75 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -18,17 +18,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 @@ -36,7 +36,7 @@ jobs: run: bun install --frozen-lockfile - name: Open or update the release PR - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: version: bun run version env: diff --git a/tests/workflow-security.test.ts b/tests/workflow-security.test.ts new file mode 100644 index 0000000..73e818e --- /dev/null +++ b/tests/workflow-security.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { join } from "node:path" + +const workflowDirectory = join(import.meta.dir, "..", ".github", "workflows") +const workflowNames = ["ci.yml", "publish.yml", "release-pr.yml"] as const + +async function readWorkflow(name: (typeof workflowNames)[number]): Promise { + return Bun.file(join(workflowDirectory, name)).text() +} + +describe("GitHub Actions security", () => { + test("pins every action to an immutable commit", async () => { + for (const name of workflowNames) { + const workflow = await readWorkflow(name) + const actionReferences = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)] + + expect(actionReferences.length).toBeGreaterThan(0) + for (const [, reference] of actionReferences) { + expect(reference).toMatch(/^[^@\s]+@[0-9a-f]{40}$/) + } + } + }) + + test("keeps pull-request CI read-only and secret-free", async () => { + const workflow = await readWorkflow("ci.yml") + + expect(workflow).toMatch(/^\s*pull_request:\s*$/m) + expect(workflow).not.toContain("pull_request_target") + expect(workflow).toMatch(/^permissions:\n\s+contents: read$/m) + expect(workflow).not.toContain("secrets.") + expect(workflow.match(/persist-credentials: false/g)).toHaveLength(2) + }) + + test("limits publishing to the merged canonical release PR", async () => { + const workflow = await readWorkflow("publish.yml") + + expect(workflow).toContain("github.repository == 'hack-dance/schema-stream'") + expect(workflow).toContain("github.event.pull_request.merged == true") + expect(workflow).toContain("github.base_ref == 'main'") + expect(workflow).toContain("github.event.pull_request.head.repo.full_name == github.repository") + expect(workflow).toContain("github.head_ref == 'changeset-release/main'") + expect(workflow).toMatch(/^\s+environment: PUBLISH$/m) + expect(workflow).toMatch(/^\s+permissions:\n\s+contents: read$/m) + expect(workflow).toContain("persist-credentials: false") + expect(workflow).not.toContain("contents: write") + }) + + test("scopes release automation to the canonical repository", async () => { + const workflow = await readWorkflow("release-pr.yml") + + expect(workflow).toContain("if: github.repository == 'hack-dance/schema-stream'") + expect(workflow).toMatch(/^permissions:\n\s+contents: write\n\s+pull-requests: write$/m) + }) +}) From 9e3dc48307f98bfb97560081733db67be8c239ad Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 10 Jul 2026 19:50:59 -0400 Subject: [PATCH 4/5] chore: adopt ultracite linting --- .github/workflows/ci.yml | 4 +- .prettierignore | 4 - .prettierrc.json | 7 -- AGENTS.md | 6 +- README.md | 6 + biome.jsonc | 132 +++++++++++++++++++ bun.lock | 195 +++++++++++++++++++++++++++-- package.json | 11 +- src/utils/buffered-string.ts | 41 +++--- src/utils/json-parser.ts | 50 ++++---- src/utils/streaming-json-parser.ts | 33 ++--- src/utils/token-parser.ts | 140 ++++++++++++--------- src/utils/token-type.ts | 24 ++-- src/utils/tokenizer.ts | 121 +++++++++--------- src/utils/zod-compat.ts | 8 +- tests/helpers.ts | 2 +- tests/iterate.test.ts | 14 ++- tests/packed-consumer/consumer.cjs | 1 + tests/parser-regressions.test.ts | 4 +- tests/snapshot-policy.benchmark.ts | 12 +- tests/snapshot-policy.test.ts | 6 +- tests/workflow-security.test.ts | 28 +++-- tests/zod-compatibility.test.ts | 2 +- tsup.config.ts | 24 ++-- 24 files changed, 625 insertions(+), 250 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc.json create mode 100644 biome.jsonc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dfd146..6030234 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,8 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Check formatting - run: bun run format:check + - name: Check formatting and linting + run: bun run lint - name: Type-check run: bun run type-check diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 821f928..0000000 --- a/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -bun.lock -coverage -dist -node_modules diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index ad7f0f6..0000000 --- a/.prettierrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "arrowParens": "avoid", - "printWidth": 100, - "semi": false, - "singleQuote": false, - "trailingComma": "none" -} diff --git a/AGENTS.md b/AGENTS.md index 81b2acb..79c0786 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,13 +17,15 @@ Use the versions declared by the repository: - `bun install` installs dependencies from `bun.lock`. - TypeScript 7 is the authoritative development compiler. - Published declarations must remain consumable by supported TypeScript 5.x projects; `bun run test:packed` verifies this with TypeScript 5.9. -- Prettier is the repository's formatter. Do not introduce a second formatter or linter without migrating the configuration, scripts, CI, and existing source in one deliberate change. +- Ultracite with Biome is the repository's formatter and linter. Keep `biome.jsonc`, package scripts, CI, and existing source synchronized when changing formatting or lint rules. Run repository scripts through Bun: ```sh bun run format bun run format:check +bun run lint +bun run lint:fix bun run type-check bun run test bun run test:coverage @@ -32,7 +34,7 @@ bun run test:packed bun run check ``` -`bun run check` is the minimum completion gate. Run `bun run build` when exports, declarations, build configuration, or package metadata change. Run `bun run test:coverage` when behavior changes. +`bun run check` is the minimum completion gate. It includes Ultracite linting and formatting checks. Run `bun run build` when exports, declarations, build configuration, or package metadata change. Run `bun run test:coverage` when behavior changes. ## Code style and architecture diff --git a/README.md b/README.md index 6e77495..dcba32a 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,8 @@ for await (const bytes of snapshots) { ```bash mise install bun install +bun run format +bun run lint bun run check ``` @@ -205,6 +207,10 @@ bun run check declarations with TypeScript 7.0.2. TypeScript is a development-only dependency, so installing `schema-stream` does not install or require TypeScript 7. +Ultracite and Biome own formatting and linting. `bun run format` applies safe fixes, `bun run lint` +checks the repository without writing, and `bun run check` includes linting before type checks, +tests, and packed-consumer verification. + `test:packed` installs the generated tarball into clean consumers and verifies ESM, CommonJS, Zod 4/Mini, Zod 3, OpenAI Agents SDK, and Vercel AI SDK compatibility with TypeScript 5.9 without contacting a model. This protects the declaration surface from accidental TS7-only syntax. diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..c62933c --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,132 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["ultracite/biome/core", "ultracite/biome/vitest"], + "formatter": { + "lineWidth": 100 + }, + "assist": { + "actions": { + "source": { + "useSortedKeys": "off", + "useSortedPackageJson": "on", + "useSortedProperties": "off" + } + } + }, + "javascript": { + "globals": ["Bun"], + "formatter": { + "arrowParentheses": "asNeeded", + "semicolons": "asNeeded", + "trailingCommas": "none" + } + }, + "json": { + "formatter": { + "lineWidth": 100 + } + }, + "linter": { + "rules": { + "style": { + "useConsistentMemberAccessibility": "off", + "useConsistentTypeDefinitions": "off" + } + } + }, + "overrides": [ + { + // This package intentionally exposes one public entry point. + "includes": ["src/index.ts"], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "off" + } + } + } + }, + { + // Numeric enums and explicit state transitions keep the streaming parser allocation-light. + "includes": [ + "src/utils/token-parser.ts", + "src/utils/token-type.ts", + "src/utils/tokenizer.ts" + ], + "linter": { + "rules": { + "complexity": { + "noExcessiveCognitiveComplexity": "off" + }, + "style": { + "noEnum": "off" + }, + "suspicious": { + "noConstEnum": "off", + "noUnnecessaryConditions": "off" + } + } + } + }, + { + // Character constants are inlined throughout the tokenizer hot path. + "includes": ["src/utils/utf-8.ts"], + "linter": { + "rules": { + "suspicious": { + "noConstEnum": "off" + } + } + } + }, + { + // Sequential reads preserve stream backpressure and cancellation semantics. + "includes": ["src/utils/streaming-json-parser.ts"], + "linter": { + "rules": { + "performance": { + "noAwaitInLoops": "off" + }, + "suspicious": { + "noUnnecessaryConditions": "off" + } + } + } + }, + { + // Zod 3 and Zod 4 definitions are narrowed across two independent runtime contracts. + "includes": ["src/utils/zod-compat.ts"], + "linter": { + "rules": { + "suspicious": { + "noUnnecessaryConditions": "off" + } + } + } + }, + { + // Compatibility fixtures intentionally exercise namespaces, sequential streams, and types. + "includes": ["tests/**/*.ts", "tests/**/*.cjs"], + "linter": { + "rules": { + "complexity": { + "noVoid": "off" + }, + "performance": { + "noAwaitInLoops": "off", + "noDelete": "off", + "noNamespaceImport": "off" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noEvolvingTypes": "off", + "noUnnecessaryConditions": "off", + "useAwait": "off" + } + } + } + } + ] +} diff --git a/bun.lock b/bun.lock index 4a592ed..932cbb1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,15 @@ "": { "name": "schema-stream", "devDependencies": { + "@biomejs/biome": "2.5.2", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", "@openai/agents": "0.13.1", "@types/bun": "^1.3.14", "ai": "7.0.19", - "prettier": "^3.9.5", "tsup": "^8.5.1", "typescript": "7.0.2", + "ultracite": "7.9.3", "zod": "^4.4.3", "zod3": "npm:zod@^3.25.0", }, @@ -33,6 +34,24 @@ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.2", "@biomejs/cli-darwin-x64": "2.5.2", "@biomejs/cli-linux-arm64": "2.5.2", "@biomejs/cli-linux-arm64-musl": "2.5.2", "@biomejs/cli-linux-x64": "2.5.2", "@biomejs/cli-linux-x64-musl": "2.5.2", "@biomejs/cli-win32-arm64": "2.5.2", "@biomejs/cli-win32-x64": "2.5.2" }, "bin": { "biome": "bin/biome" } }, "sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A=="], + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.1.1", "", { "dependencies": { "@changesets/config": "^3.1.4", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.10", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A=="], @@ -71,6 +90,10 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], + + "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -123,8 +146,32 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -209,12 +256,30 @@ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], @@ -263,6 +328,8 @@ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "ai": ["ai@7.0.19", "", { "dependencies": { "@ai-sdk/gateway": "4.0.15", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.7" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7kxMNiy6JqCvruCY2qGMNzeqEt9Ey3XqrtYGHWyipDJTXHFN7gOyO1UR2UIXhw112vAUkrO+OJJzemKJD8P3bA=="], "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -279,10 +346,14 @@ "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -301,7 +372,9 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], @@ -323,6 +396,10 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -349,8 +426,26 @@ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -367,12 +462,24 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.3", "", {}, "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -381,6 +488,10 @@ "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -395,7 +506,9 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], @@ -417,6 +530,8 @@ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -443,14 +558,24 @@ "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -461,6 +586,8 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -477,6 +604,10 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -485,10 +616,14 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "nypm": ["nypm@0.6.8", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -499,6 +634,8 @@ "openai": ["openai@6.46.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], @@ -519,6 +656,8 @@ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], @@ -539,10 +678,14 @@ "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], @@ -593,6 +736,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], @@ -627,22 +772,30 @@ "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + "ultracite": ["ultracite@7.9.3", "", { "dependencies": { "@clack/prompts": "^1.5.1", "@typescript-eslint/utils": "^8.62.1", "commander": "^15.0.0", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-MqF0cn5DNHy/I61+hL4aYPRVye+rrmUqfv1dhwQ0ORfoFngk1O8tTKigF+zpsh/6qA84gkl26KkrtYzoypPiaw=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], @@ -651,20 +804,22 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "zod3": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - - "@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], - "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -673,14 +828,38 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "eslint/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "read-yaml-file/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "eslint/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "eslint/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "eslint/find-up/locate-path/p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], } } diff --git a/package.json b/package.json index b94189f..5abd8f9 100644 --- a/package.json +++ b/package.json @@ -52,12 +52,14 @@ "scripts": { "benchmark:snapshots": "bun tests/snapshot-policy.benchmark.ts", "build": "tsup && tsc --project tsconfig.build.json", - "check": "bun run format:check && bun run type-check && bun run test && bun run test:packed", + "check": "bun run lint && bun run type-check && bun run test && bun run test:packed", "changeset": "changeset", "clean": "rm -rf coverage dist node_modules", "dev": "tsup --watch", - "format": "prettier --write .", - "format:check": "prettier --check .", + "format": "ultracite fix", + "format:check": "ultracite check", + "lint": "ultracite check", + "lint:fix": "ultracite fix", "prepack": "bun run build", "release": "bun run build && changeset publish", "test": "bun test --timeout=25000", @@ -67,14 +69,15 @@ "version": "changeset version" }, "devDependencies": { + "@biomejs/biome": "2.5.2", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.31.0", "@openai/agents": "0.13.1", "@types/bun": "^1.3.14", "ai": "7.0.19", - "prettier": "^3.9.5", "tsup": "^8.5.1", "typescript": "7.0.2", + "ultracite": "7.9.3", "zod": "^4.4.3", "zod3": "npm:zod@^3.25.0" }, diff --git a/src/utils/buffered-string.ts b/src/utils/buffered-string.ts index 37d6d22..348202e 100644 --- a/src/utils/buffered-string.ts +++ b/src/utils/buffered-string.ts @@ -9,19 +9,19 @@ */ export interface StringBuilder { - byteLength: number - appendChar: (char: number) => void appendBuf: (buf: Uint8Array, start?: number, end?: number) => void + appendChar: (char: number) => void appendString: (value: string) => void + byteLength: number reset: () => void toString: () => string } export class NonBufferedString implements StringBuilder { - private decoder = new TextDecoder("utf-8") - private encoder = new TextEncoder() + private readonly decoder = new TextDecoder("utf-8") + private readonly encoder = new TextEncoder() private string = "" - private onIncrementalString?: (str: string) => void + private readonly onIncrementalString?: (str: string) => void public byteLength = 0 @@ -48,7 +48,9 @@ export class NonBufferedString implements StringBuilder { } private update(): void { - if (this.onIncrementalString) this.onIncrementalString(this.string) + if (this.onIncrementalString) { + this.onIncrementalString(this.string) + } } public reset(): void { @@ -62,12 +64,12 @@ export class NonBufferedString implements StringBuilder { } export class BufferedString implements StringBuilder { - private decoder = new TextDecoder("utf-8") - private encoder = new TextEncoder() - private buffer: Uint8Array + private readonly decoder = new TextDecoder("utf-8") + private readonly encoder = new TextEncoder() + private readonly buffer: Uint8Array private bufferOffset = 0 private string = "" - private onIncrementalString?: (str: string) => void + private readonly onIncrementalString?: (str: string) => void public byteLength = 0 @@ -77,14 +79,19 @@ export class BufferedString implements StringBuilder { } public appendChar(char: number): void { - if (this.bufferOffset >= this.buffer.length) this.flushStringBuffer() - this.buffer[this.bufferOffset++] = char + if (this.bufferOffset >= this.buffer.length) { + this.flushStringBuffer() + } + this.buffer[this.bufferOffset] = char + this.bufferOffset += 1 this.byteLength += 1 } public appendBuf(buf: Uint8Array, start = 0, end: number = buf.length): void { const size = end - start - if (this.bufferOffset + size > this.buffer.length) this.flushStringBuffer() + if (this.bufferOffset + size > this.buffer.length) { + this.flushStringBuffer() + } if (size > this.buffer.length) { this.string += this.decoder.decode(buf.subarray(start, end)) @@ -106,7 +113,9 @@ export class BufferedString implements StringBuilder { } private flushStringBuffer(): void { - if (this.bufferOffset === 0) return + if (this.bufferOffset === 0) { + return + } this.string += this.decoder.decode(this.buffer.subarray(0, this.bufferOffset)) this.bufferOffset = 0 @@ -114,7 +123,9 @@ export class BufferedString implements StringBuilder { } private update(): void { - if (this.onIncrementalString) this.onIncrementalString(this.string) + if (this.onIncrementalString) { + this.onIncrementalString(this.string) + } } public reset(): void { diff --git a/src/utils/json-parser.ts b/src/utils/json-parser.ts index 8f4cad1..8d5c74b 100644 --- a/src/utils/json-parser.ts +++ b/src/utils/json-parser.ts @@ -1,11 +1,11 @@ import TokenParser, { - JsonKey, - ParsedElementInfo, - ParsedTokenInfo, - StackElement, - TokenParserMode, - TokenParserState, - type TokenParserOptions + type JsonKey, + type ParsedElementInfo, + type ParsedTokenInfo, + type StackElement, + type TokenParserMode, + type TokenParserOptions, + TokenParserState } from "./token-parser" import TokenType from "./token-type" import Tokenizer, { type TokenizerOptions } from "./tokenizer" @@ -13,8 +13,8 @@ import Tokenizer, { type TokenizerOptions } from "./tokenizer" export interface JSONParserOptions extends TokenizerOptions, TokenParserOptions {} export default class JSONParser { - private tokenizer: Tokenizer - private tokenParser: TokenParser + private readonly tokenizer: Tokenizer + private readonly tokenParser: TokenParser constructor(opts: JSONParserOptions = {}) { this.tokenizer = new Tokenizer(opts) @@ -22,12 +22,16 @@ export default class JSONParser { this.tokenizer.onToken = this.tokenParser.write.bind(this.tokenParser) this.tokenizer.onEnd = () => { - if (!this.tokenParser.isEnded) this.tokenParser.end() + if (!this.tokenParser.isEnded) { + this.tokenParser.end() + } } this.tokenParser.onError = this.tokenizer.error.bind(this.tokenizer) this.tokenParser.onEnd = () => { - if (!this.tokenizer.isEnded) this.tokenizer.end() + if (!this.tokenizer.isEnded) { + this.tokenizer.end() + } } } @@ -43,17 +47,15 @@ export default class JSONParser { this.tokenizer.end() } - public set onToken( - cb: (parsedTokenInfo: { - parser: { - state: TokenParserState - key: JsonKey - mode: TokenParserMode | undefined - stack: StackElement[] - } - tokenizer: ParsedTokenInfo - }) => void - ) { + public set onToken(cb: (parsedTokenInfo: { + parser: { + state: TokenParserState + key: JsonKey + mode: TokenParserMode | undefined + stack: StackElement[] + } + tokenizer: ParsedTokenInfo + }) => void) { this.tokenizer.onToken = parsedToken => { const valueTokenTypes = [ TokenType.STRING, @@ -92,7 +94,9 @@ export default class JSONParser { public set onEnd(cb: () => void) { this.tokenParser.onEnd = () => { - if (!this.tokenizer.isEnded) this.tokenizer.end() + if (!this.tokenizer.isEnded) { + this.tokenizer.end() + } cb.call(this.tokenParser) } } diff --git a/src/utils/streaming-json-parser.ts b/src/utils/streaming-json-parser.ts index ec85a25..0809993 100644 --- a/src/utils/streaming-json-parser.ts +++ b/src/utils/streaming-json-parser.ts @@ -80,17 +80,18 @@ export type SchemaStreamInputChunk = string | Uint8Array /** A Web Stream or async iterable that supplies JSON text or UTF-8 bytes. */ export type SchemaStreamSource = - ReadableStream | AsyncIterable + | ReadableStream + | AsyncIterable type OpenSource = { iterator: AsyncIterator - finish(cancel: boolean): Promise + finish: (cancel: boolean) => Promise } type JsonContainer = Record function hasOwn(value: object, key: PropertyKey): boolean { - return Object.prototype.hasOwnProperty.call(value, key) + return Object.hasOwn(value, key) } function isObject(value: unknown): value is Record { @@ -132,7 +133,7 @@ function setPathValue(target: Record, path: SchemaPath, value: current = nextValue } - const finalSegment = path[path.length - 1] + const finalSegment = path.at(-1) if (finalSegment !== undefined) { setOwnValue(current, finalSegment, value) } @@ -141,8 +142,12 @@ function setPathValue(target: Record, path: SchemaPath, value: function getPathKey(path: SchemaPath): string { return path .map(segment => { - if (segment === undefined) return "u" - if (typeof segment === "number") return `n:${segment}` + if (segment === undefined) { + return "u" + } + if (typeof segment === "number") { + return `n:${segment}` + } return `s:${segment.length}:${segment}` }) .join("|") @@ -202,10 +207,10 @@ function openSource( * @typeParam TSchema - Object schema that determines placeholders and snapshot inference. */ export class SchemaStream { - private schemaInstance: Record + private readonly schemaInstance: Record private activePath: SchemaPath = [] - private completedPaths: SchemaPath[] = [] - private completedPathKeys = new Set() + private readonly completedPaths: SchemaPath[] = [] + private readonly completedPathKeys = new Set() private readonly onKeyComplete?: OnKeyCompleteCallback private readonly typeDefaults?: TypeDefaults @@ -276,6 +281,8 @@ export class SchemaStream { case "null": case "unknown": return null + default: + return null } } @@ -305,10 +312,7 @@ export class SchemaStream { return this.createBlankObjectFromShape(getObjectShape(schema), defaultData, new Set([schema])) } - private getPathFromStack( - stack: StackElement[] = [], - key: string | number | undefined - ): SchemaPath { + private getPathFromStack(stack: StackElement[], key: string | number | undefined): SchemaPath { return [...stack.map(element => element.key), key].slice(1) } @@ -374,8 +378,7 @@ export class SchemaStream { const snapshotPolicy = options.snapshotPolicy ?? { mode: "chunk" } if ( snapshotPolicy.mode === "bytes" && - (!Number.isFinite(snapshotPolicy.bytes) || - !Number.isInteger(snapshotPolicy.bytes) || + (!(Number.isFinite(snapshotPolicy.bytes) && Number.isInteger(snapshotPolicy.bytes)) || snapshotPolicy.bytes <= 0) ) { throw new TypeError("snapshotPolicy.bytes must be a positive, finite integer") diff --git a/src/utils/token-parser.ts b/src/utils/token-parser.ts index 5b11ca9..ba5518f 100644 --- a/src/utils/token-parser.ts +++ b/src/utils/token-parser.ts @@ -17,39 +17,39 @@ export type JsonArray = (JsonPrimitive | JsonStruct)[] export type JsonStruct = JsonObject | JsonArray export const enum TokenParserMode { - OBJECT, - ARRAY + OBJECT = 0, + ARRAY = 1 } export interface StackElement { + emit: boolean key: JsonKey - value: JsonStruct mode?: TokenParserMode - emit: boolean + value: JsonStruct } export interface ParsedTokenInfo { - token: TokenType - value: JsonPrimitive offset?: number partial?: boolean + token: TokenType + value: JsonPrimitive } export interface ParsedElementInfo { - value: JsonPrimitive | JsonStruct - parent?: JsonStruct key?: JsonKey + parent?: JsonStruct stack: StackElement[] + value: JsonPrimitive | JsonStruct } export const enum TokenParserState { - VALUE, - KEY, - COLON, - COMMA, - ENDED, - ERROR, - SEPARATOR + VALUE = 0, + KEY = 1, + COLON = 2, + COMMA = 3, + ENDED = 4, + ERROR = 5, + SEPARATOR = 6 } function tokenParserStateToString(state: TokenParserState): string { @@ -57,8 +57,8 @@ function tokenParserStateToString(state: TokenParserState): string { } export interface TokenParserOptions { - paths?: string[] keepStack?: boolean + paths?: string[] separator?: string } @@ -86,41 +86,60 @@ export default class TokenParser { stack: StackElement[] = [] constructor(opts?: TokenParserOptions) { - opts = { ...defaultOpts, ...opts } - - if (opts.paths) { - this.paths = opts.paths.map(path => { - if (path === undefined || path === "$*") return undefined + const options = { ...defaultOpts, ...opts } + + if (options.paths) { + const paths: (string[] | undefined)[] = [] + for (const path of options.paths) { + if (path === undefined || path === "$*") { + paths.push(undefined) + continue + } - if (!path.startsWith("$")) + if (!path.startsWith("$")) { throw new TokenParserError(`Invalid selector "${path}". Should start with "$".`) + } const pathParts = path.split(".").slice(1) - if (pathParts.includes("")) + if (pathParts.includes("")) { throw new TokenParserError(`Invalid selector "${path}". ".." syntax not supported.`) - return pathParts - }) + } + paths.push(pathParts) + } + this.paths = paths } - this.keepStack = opts.keepStack ?? true - this.separator = opts.separator + this.keepStack = options.keepStack ?? true + this.separator = options.separator } private shouldEmit(): boolean { - if (!this.paths) return true + if (!this.paths) { + return true + } return this.paths.some(path => { - if (path === undefined) return true - if (path.length !== this.stack.length) return false + if (path === undefined) { + return true + } + if (path.length !== this.stack.length) { + return false + } - for (let i = 0; i < path.length - 1; i++) { + for (let i = 0; i < path.length - 1; i += 1) { const selector = path[i] - const key = this.stack[i + 1].key - if (selector === "*") continue - if (selector !== key) return false + const { key } = this.stack[i + 1] + if (selector === "*") { + continue + } + if (selector !== key) { + return false + } } - const selector = path[path.length - 1] - if (selector === "*") return true + const selector = path.at(-1) + if (selector === "*") { + return true + } return selector === this.key?.toString() }) } @@ -135,17 +154,18 @@ export default class TokenParser { } private pop(): void { - const value = this.value + const { value } = this + const stackElement = this.stack.pop() + if (!stackElement) { + throw new TokenParserError("Unexpected container end without a parent stack entry") + } - let emit - ;({ - key: this.key, - value: this.value, - mode: this.mode, - emit - } = this.stack.pop() as StackElement) + const { emit, key, mode, value: parentValue } = stackElement + this.key = key + this.mode = mode + this.value = parentValue - this.state = this.mode !== undefined ? TokenParserState.COMMA : TokenParserState.VALUE + this.state = this.mode === undefined ? TokenParserState.VALUE : TokenParserState.COMMA this.emit(value as JsonPrimitive | JsonStruct, emit) } @@ -161,7 +181,7 @@ export default class TokenParser { if (emit) { this.onValue({ - value: value, + value, key: this.key, parent: this.value, stack: this.stack @@ -211,7 +231,9 @@ export default class TokenParser { if (token === TokenType.LEFT_BRACE) { this.push() if (this.mode === TokenParserMode.OBJECT) { - this.value = (this.value as JsonObject)[this.key as string] = {} + const object: JsonObject = {} + ;(this.value as JsonObject)[this.key as string] = object + this.value = object } else if (this.mode === TokenParserMode.ARRAY) { const val = {} ;(this.value as JsonArray).push(val) @@ -228,7 +250,9 @@ export default class TokenParser { if (token === TokenType.LEFT_BRACKET) { this.push() if (this.mode === TokenParserMode.OBJECT) { - this.value = (this.value as JsonObject)[this.key as string] = [] + const array: JsonArray = [] + ;(this.value as JsonObject)[this.key as string] = array + this.value = array } else if (this.mode === TokenParserMode.ARRAY) { const val: JsonArray = [] ;(this.value as JsonArray).push(val) @@ -265,11 +289,9 @@ export default class TokenParser { } } - if (this.state === TokenParserState.COLON) { - if (token === TokenType.COLON) { - this.state = TokenParserState.VALUE - return - } + if (this.state === TokenParserState.COLON && token === TokenType.COLON) { + this.state = TokenParserState.VALUE + return } if (this.state === TokenParserState.COMMA) { @@ -295,11 +317,13 @@ export default class TokenParser { } } - if (this.state === TokenParserState.SEPARATOR) { - if (token === TokenType.SEPARATOR && value === this.separator) { - this.state = TokenParserState.VALUE - return - } + if ( + this.state === TokenParserState.SEPARATOR && + token === TokenType.SEPARATOR && + value === this.separator + ) { + this.state = TokenParserState.VALUE + return } throw new TokenParserError( diff --git a/src/utils/token-type.ts b/src/utils/token-type.ts index 9565955..0eb19f5 100644 --- a/src/utils/token-type.ts +++ b/src/utils/token-type.ts @@ -1,16 +1,16 @@ enum TokenType { - LEFT_BRACE, - RIGHT_BRACE, - LEFT_BRACKET, - RIGHT_BRACKET, - COLON, - COMMA, - TRUE, - FALSE, - NULL, - STRING, - NUMBER, - SEPARATOR + LEFT_BRACE = 0, + RIGHT_BRACE = 1, + LEFT_BRACKET = 2, + RIGHT_BRACKET = 3, + COLON = 4, + COMMA = 5, + TRUE = 6, + FALSE = 7, + NULL = 8, + STRING = 9, + NUMBER = 10, + SEPARATOR = 11 } export function TokenTypeToString(tokenType: TokenType): string { diff --git a/src/utils/tokenizer.ts b/src/utils/tokenizer.ts index 00a0e51..226201b 100644 --- a/src/utils/tokenizer.ts +++ b/src/utils/tokenizer.ts @@ -15,35 +15,35 @@ import { charset, escapedSequences } from "./utf-8.js" // Tokenizer States const enum TokenizerStates { - START, - ENDED, - ERROR, - TRUE1, - TRUE2, - TRUE3, - FALSE1, - FALSE2, - FALSE3, - FALSE4, - NULL1, - NULL2, - NULL3, - STRING_DEFAULT, - STRING_AFTER_BACKSLASH, - STRING_UNICODE_DIGIT_1, - STRING_UNICODE_DIGIT_2, - STRING_UNICODE_DIGIT_3, - STRING_UNICODE_DIGIT_4, - STRING_INCOMPLETE_CHAR, - NUMBER_AFTER_INITIAL_MINUS, - NUMBER_AFTER_INITIAL_ZERO, - NUMBER_AFTER_INITIAL_NON_ZERO, - NUMBER_AFTER_FULL_STOP, - NUMBER_AFTER_DECIMAL, - NUMBER_AFTER_E, - NUMBER_AFTER_E_AND_SIGN, - NUMBER_AFTER_E_AND_DIGIT, - SEPARATOR + START = 0, + ENDED = 1, + ERROR = 2, + TRUE1 = 3, + TRUE2 = 4, + TRUE3 = 5, + FALSE1 = 6, + FALSE2 = 7, + FALSE3 = 8, + FALSE4 = 9, + NULL1 = 10, + NULL2 = 11, + NULL3 = 12, + STRING_DEFAULT = 13, + STRING_AFTER_BACKSLASH = 14, + STRING_UNICODE_DIGIT_1 = 15, + STRING_UNICODE_DIGIT_2 = 16, + STRING_UNICODE_DIGIT_3 = 17, + STRING_UNICODE_DIGIT_4 = 18, + STRING_INCOMPLETE_CHAR = 19, + NUMBER_AFTER_INITIAL_MINUS = 20, + NUMBER_AFTER_INITIAL_ZERO = 21, + NUMBER_AFTER_INITIAL_NON_ZERO = 22, + NUMBER_AFTER_FULL_STOP = 23, + NUMBER_AFTER_DECIMAL = 24, + NUMBER_AFTER_E = 25, + NUMBER_AFTER_E_AND_SIGN = 26, + NUMBER_AFTER_E_AND_DIGIT = 27, + SEPARATOR = 28 } function tokenizerStateToString(tokenizerState: TokenizerStates): string { @@ -81,10 +81,10 @@ function tokenizerStateToString(tokenizerState: TokenizerStates): string { } export interface TokenizerOptions { - stringBufferSize?: number + handleUnescapedNewLines?: boolean numberBufferSize?: number separator?: string - handleUnescapedNewLines?: boolean + stringBufferSize?: number } const defaultOpts: TokenizerOptions = { @@ -104,30 +104,32 @@ export class TokenizerError extends Error { export default class Tokenizer { private state = TokenizerStates.START - private handleUnescapedNewLines?: boolean - private separator?: string - private separatorBytes?: Uint8Array + private readonly handleUnescapedNewLines?: boolean + private readonly separator?: string + private readonly separatorBytes?: Uint8Array private separatorIndex = 0 - private bufferedString: StringBuilder - private bufferedNumber: StringBuilder + private readonly bufferedString: StringBuilder + private readonly bufferedNumber: StringBuilder private unicode?: string // unicode escapes private highSurrogate?: number private bytesRemaining = 0 private bytesInSequence = 0 - private charSplitBuffer = new Uint8Array(4) - private encoder = new TextEncoder() + private readonly charSplitBuffer = new Uint8Array(4) + private readonly encoder = new TextEncoder() private offset = -1 private appendPendingHighSurrogate(): void { - if (this.highSurrogate === undefined) return + if (this.highSurrogate === undefined) { + return + } this.bufferedString.appendString(String.fromCharCode(this.highSurrogate)) this.highSurrogate = undefined } constructor(opts?: TokenizerOptions) { - opts = { ...defaultOpts, ...opts } + const options = { ...defaultOpts, ...opts } const onIncrementalString = (str: string) => { this.onToken({ @@ -138,20 +140,20 @@ export default class Tokenizer { } this.bufferedString = - opts?.stringBufferSize && opts.stringBufferSize > 0 - ? new BufferedString(opts.stringBufferSize) + options.stringBufferSize && options.stringBufferSize > 0 + ? new BufferedString(options.stringBufferSize) : new NonBufferedString({ onIncrementalString }) this.bufferedNumber = - opts?.numberBufferSize && opts.numberBufferSize > 0 - ? new BufferedString(opts.numberBufferSize, onIncrementalString) + options.numberBufferSize && options.numberBufferSize > 0 + ? new BufferedString(options.numberBufferSize, onIncrementalString) : new NonBufferedString({}) - this.handleUnescapedNewLines = opts?.handleUnescapedNewLines ?? false - this.separator = opts?.separator - this.separatorBytes = opts?.separator ? this.encoder.encode(opts.separator) : undefined + this.handleUnescapedNewLines = options.handleUnescapedNewLines ?? false + this.separator = options.separator + this.separatorBytes = options.separator ? this.encoder.encode(options.separator) : undefined } public get isEnded(): boolean { @@ -401,26 +403,24 @@ export default class Tokenizer { (n >= charset.LATIN_CAPITAL_LETTER_A && n <= charset.LATIN_CAPITAL_LETTER_F) || (n >= charset.LATIN_SMALL_LETTER_A && n <= charset.LATIN_SMALL_LETTER_F) ) { - const intVal = parseInt(this.unicode + String.fromCharCode(n), 16) + const intVal = Number.parseInt(this.unicode + String.fromCharCode(n), 16) if (this.highSurrogate === undefined) { - if (intVal >= 0xd800 && intVal <= 0xdbff) { + if (intVal >= 0xd8_00 && intVal <= 0xdb_ff) { //<55296,56319> - highSurrogate this.highSurrogate = intVal } else { this.bufferedString.appendString(String.fromCharCode(intVal)) } + } else if (intVal >= 0xdc_00 && intVal <= 0xdf_ff) { + //<56320,57343> - lowSurrogate + this.bufferedString.appendString(String.fromCharCode(this.highSurrogate, intVal)) + this.highSurrogate = undefined } else { - if (intVal >= 0xdc00 && intVal <= 0xdfff) { - //<56320,57343> - lowSurrogate - this.bufferedString.appendString(String.fromCharCode(this.highSurrogate, intVal)) - this.highSurrogate = undefined + this.appendPendingHighSurrogate() + if (intVal >= 0xd8_00 && intVal <= 0xdb_ff) { + this.highSurrogate = intVal } else { - this.appendPendingHighSurrogate() - if (intVal >= 0xd800 && intVal <= 0xdbff) { - this.highSurrogate = intVal - } else { - this.bufferedString.appendString(String.fromCharCode(intVal)) - } + this.bufferedString.appendString(String.fromCharCode(intVal)) } } this.state = TokenizerStates.STRING_DEFAULT @@ -643,6 +643,9 @@ export default class Tokenizer { // whitespace continue } + break + default: + break } throw new TokenizerError( diff --git a/src/utils/zod-compat.ts b/src/utils/zod-compat.ts index 1d276c2..ffab7e7 100644 --- a/src/utils/zod-compat.ts +++ b/src/utils/zod-compat.ts @@ -1,5 +1,7 @@ import type * as z4 from "zod/v4/core" +const zodTypePrefixPattern = /^Zod/ + /** Minimal structural contract used to support a Zod 3 schema without importing its runtime. */ export type Zod3Schema = { readonly _def: unknown @@ -90,7 +92,7 @@ function getSchemaDefinition(schema: ZodSchema): SchemaDefinition { return { major: 3, - type: definition.typeName?.replace(/^Zod/, "").toLowerCase() ?? "unknown", + type: definition.typeName?.replace(zodTypePrefixPattern, "").toLowerCase() ?? "unknown", definition } } @@ -124,7 +126,7 @@ export function getSchemaNode(schema: ZodSchema): SchemaNode { const schemaDefinition = getSchemaDefinition(schema) if (schemaDefinition.major === 4) { - const definition = schemaDefinition.definition + const { definition } = schemaDefinition switch (definition.type) { case "array": @@ -157,7 +159,7 @@ export function getSchemaNode(schema: ZodSchema): SchemaNode { } } - const definition = schemaDefinition.definition + const { definition } = schemaDefinition switch (schemaDefinition.type) { case "array": diff --git a/tests/helpers.ts b/tests/helpers.ts index e8d9de5..895bcd7 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,6 @@ import { - SchemaStream, type OnKeyCompleteCallbackParams, + SchemaStream, type SchemaStreamChunk, type SchemaStreamOptions, type SchemaStreamParseOptions, diff --git a/tests/iterate.test.ts b/tests/iterate.test.ts index 77c3b5c..cecf331 100644 --- a/tests/iterate.test.ts +++ b/tests/iterate.test.ts @@ -1,6 +1,6 @@ -import { SchemaStream, type SchemaStreamChunk, type SnapshotPolicy } from "@/index" import { describe, expect, test } from "bun:test" import * as z from "zod" +import { SchemaStream, type SchemaStreamChunk, type SnapshotPolicy } from "@/index" async function collect( stream: AsyncIterable> @@ -58,7 +58,9 @@ describe("SchemaStream.iterate", () => { const chunks = ['{"title":"hel', 'lo","nested":{"count":', "2}}"] const source = new ReadableStream({ start(controller) { - for (const chunk of chunks) controller.enqueue(chunk) + for (const chunk of chunks) { + controller.enqueue(chunk) + } controller.close() } }) @@ -85,7 +87,9 @@ describe("SchemaStream.iterate", () => { ] const source = new ReadableStream({ start(controller) { - for (const chunk of chunks) controller.enqueue(chunk) + for (const chunk of chunks) { + controller.enqueue(chunk) + } controller.close() } }) @@ -233,7 +237,9 @@ describe("SchemaStream.iterate", () => { const first = (await iterator.next()).value expect(first).toBeDefined() - if (!first) throw new Error("expected the first snapshot") + if (!first) { + throw new Error("expected the first snapshot") + } first.nested!.text = "consumer mutation" first.items!.push({ name: "consumer item" }) diff --git a/tests/packed-consumer/consumer.cjs b/tests/packed-consumer/consumer.cjs index f0f3419..f0a96ac 100644 --- a/tests/packed-consumer/consumer.cjs +++ b/tests/packed-consumer/consumer.cjs @@ -1,3 +1,4 @@ +"use strict" const schemaStream = require("schema-stream") if (typeof schemaStream.SchemaStream !== "function") { diff --git a/tests/parser-regressions.test.ts b/tests/parser-regressions.test.ts index d6bc5d2..6610d90 100644 --- a/tests/parser-regressions.test.ts +++ b/tests/parser-regressions.test.ts @@ -1,6 +1,6 @@ -import { SchemaStream } from "@/index" import { describe, expect, test } from "bun:test" import * as z from "zod" +import { SchemaStream } from "@/index" import { collectEmissions } from "./helpers" @@ -63,7 +63,7 @@ describe("stream parser regressions", () => { test("preserves escaped lone, repeated, and paired UTF-16 surrogates", async () => { const schema = z.object({ text: z.string() }) - const highSurrogate = String.fromCharCode(0xd83d) + const highSurrogate = String.fromCharCode(0xd8_3d) const data = { text: `${highSurrogate}X|${highSurrogate}${highSurrogate}|🌊|${highSurrogate}\n` } diff --git a/tests/snapshot-policy.benchmark.ts b/tests/snapshot-policy.benchmark.ts index 753b4f5..b7efd6a 100644 --- a/tests/snapshot-policy.benchmark.ts +++ b/tests/snapshot-policy.benchmark.ts @@ -1,5 +1,5 @@ -import { SchemaStream, type SnapshotPolicy } from "@/index" import * as z from "zod" +import { SchemaStream, type SnapshotPolicy } from "@/index" interface BenchmarkResult { durationSeconds: string @@ -12,8 +12,8 @@ interface BenchmarkResult { const encoder = new TextEncoder() const decoder = new TextDecoder() const networkChunkSize = 64 * 1024 -const payloadSizeMb = Number(Bun.argv[2] ?? 10) -const selectedMode = Bun.argv[3] +const [bunExecutable, , payloadSizeArgument, selectedMode] = Bun.argv +const payloadSizeMb = Number(payloadSizeArgument ?? 10) if (!Number.isFinite(payloadSizeMb) || payloadSizeMb <= 0) { throw new TypeError("Payload size must be a positive number of megabytes") @@ -54,7 +54,9 @@ async function runPolicy({ const readPromise = (async () => { while (true) { const output = await reader.read() - if (output.done) return + if (output.done) { + return + } emittedBytes += output.value.byteLength emittedSnapshots += 1 finalSnapshot = output.value @@ -95,7 +97,7 @@ if (selectedMode) { const results: BenchmarkResult[] = [] for (const policy of policies) { const process = Bun.spawn( - [Bun.argv[0] as string, import.meta.path, String(payloadSizeMb), policy.mode], + [bunExecutable, import.meta.path, String(payloadSizeMb), policy.mode], { stderr: "inherit", stdout: "pipe" } ) const output = await new Response(process.stdout).text() diff --git a/tests/snapshot-policy.test.ts b/tests/snapshot-policy.test.ts index 01c8535..cdae163 100644 --- a/tests/snapshot-policy.test.ts +++ b/tests/snapshot-policy.test.ts @@ -209,12 +209,14 @@ describe("snapshot policies", () => { }) const expected = { content: "x".repeat(2 * 1024 * 1024), - records: Array.from({ length: 1_000 }, (_, id) => ({ id, active: id % 2 === 0 })) + records: Array.from({ length: 1000 }, (_, id) => ({ id, active: id % 2 === 0 })) } const chunks = splitBytes(JSON.stringify(expected), 64 * 1024) const source = new ReadableStream({ start(controller) { - for (const chunk of chunks) controller.enqueue(chunk) + for (const chunk of chunks) { + controller.enqueue(chunk) + } controller.close() } }) diff --git a/tests/workflow-security.test.ts b/tests/workflow-security.test.ts index 73e818e..285483a 100644 --- a/tests/workflow-security.test.ts +++ b/tests/workflow-security.test.ts @@ -3,8 +3,16 @@ import { join } from "node:path" const workflowDirectory = join(import.meta.dir, "..", ".github", "workflows") const workflowNames = ["ci.yml", "publish.yml", "release-pr.yml"] as const - -async function readWorkflow(name: (typeof workflowNames)[number]): Promise { +const actionReferencePattern = /^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm +const immutableActionReferencePattern = /^[^@\s]+@[0-9a-f]{40}$/ +const pullRequestTriggerPattern = /^\s*pull_request:\s*$/m +const readOnlyPermissionsPattern = /^permissions:\n\s+contents: read$/m +const persistedCredentialsPattern = /persist-credentials: false/g +const publishEnvironmentPattern = /^\s+environment: PUBLISH$/m +const publishPermissionsPattern = /^\s+permissions:\n\s+contents: read$/m +const releasePermissionsPattern = /^permissions:\n\s+contents: write\n\s+pull-requests: write$/m + +function readWorkflow(name: (typeof workflowNames)[number]): Promise { return Bun.file(join(workflowDirectory, name)).text() } @@ -12,11 +20,11 @@ describe("GitHub Actions security", () => { test("pins every action to an immutable commit", async () => { for (const name of workflowNames) { const workflow = await readWorkflow(name) - const actionReferences = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)] + const actionReferences = [...workflow.matchAll(actionReferencePattern)] expect(actionReferences.length).toBeGreaterThan(0) for (const [, reference] of actionReferences) { - expect(reference).toMatch(/^[^@\s]+@[0-9a-f]{40}$/) + expect(reference).toMatch(immutableActionReferencePattern) } } }) @@ -24,11 +32,11 @@ describe("GitHub Actions security", () => { test("keeps pull-request CI read-only and secret-free", async () => { const workflow = await readWorkflow("ci.yml") - expect(workflow).toMatch(/^\s*pull_request:\s*$/m) + expect(workflow).toMatch(pullRequestTriggerPattern) expect(workflow).not.toContain("pull_request_target") - expect(workflow).toMatch(/^permissions:\n\s+contents: read$/m) + expect(workflow).toMatch(readOnlyPermissionsPattern) expect(workflow).not.toContain("secrets.") - expect(workflow.match(/persist-credentials: false/g)).toHaveLength(2) + expect(workflow.match(persistedCredentialsPattern)).toHaveLength(2) }) test("limits publishing to the merged canonical release PR", async () => { @@ -39,8 +47,8 @@ describe("GitHub Actions security", () => { expect(workflow).toContain("github.base_ref == 'main'") expect(workflow).toContain("github.event.pull_request.head.repo.full_name == github.repository") expect(workflow).toContain("github.head_ref == 'changeset-release/main'") - expect(workflow).toMatch(/^\s+environment: PUBLISH$/m) - expect(workflow).toMatch(/^\s+permissions:\n\s+contents: read$/m) + expect(workflow).toMatch(publishEnvironmentPattern) + expect(workflow).toMatch(publishPermissionsPattern) expect(workflow).toContain("persist-credentials: false") expect(workflow).not.toContain("contents: write") }) @@ -49,6 +57,6 @@ describe("GitHub Actions security", () => { const workflow = await readWorkflow("release-pr.yml") expect(workflow).toContain("if: github.repository == 'hack-dance/schema-stream'") - expect(workflow).toMatch(/^permissions:\n\s+contents: write\n\s+pull-requests: write$/m) + expect(workflow).toMatch(releasePermissionsPattern) }) }) diff --git a/tests/zod-compatibility.test.ts b/tests/zod-compatibility.test.ts index c399799..1128fb8 100644 --- a/tests/zod-compatibility.test.ts +++ b/tests/zod-compatibility.test.ts @@ -1,8 +1,8 @@ -import { SchemaStream, type SchemaStreamChunk } from "@/index" import { describe, expect, test } from "bun:test" import * as z4 from "zod" import * as zm from "zod/mini" import * as z3 from "zod3" +import { SchemaStream, type SchemaStreamChunk } from "@/index" import { collectEmissions } from "./helpers" diff --git a/tsup.config.ts b/tsup.config.ts index b25b1bd..88fc5e2 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,15 +1,13 @@ import { defineConfig } from "tsup" -export default defineConfig(options => { - return { - clean: !options.watch, - entry: ["src/index.ts"], - dts: false, - watch: options.watch, - sourcemap: true, - minify: true, - target: "es2020", - format: ["cjs", "esm"], - external: ["zod"] - } -}) +export default defineConfig(options => ({ + clean: !options.watch, + entry: ["src/index.ts"], + dts: false, + watch: options.watch, + sourcemap: true, + minify: true, + target: "es2020", + format: ["cjs", "esm"], + external: ["zod"] +})) From 4bd637f0c73a9699bd1124330543391724ca7c1a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Fri, 10 Jul 2026 19:54:53 -0400 Subject: [PATCH 5/5] fix: skip snapshots for empty sources --- src/utils/streaming-json-parser.ts | 11 +++++++++-- tests/iterate.test.ts | 22 ++++++++++++++++++++++ tests/snapshot-policy.test.ts | 19 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/utils/streaming-json-parser.ts b/src/utils/streaming-json-parser.ts index 0809993..e297c51 100644 --- a/src/utils/streaming-json-parser.ts +++ b/src/utils/streaming-json-parser.ts @@ -391,6 +391,7 @@ export class SchemaStream { let completedValuesSinceEmission = 0 let parserRevision = 0 let emittedRevision = -1 + let hasParsedValue = false parser.onToken = parsedToken => { const completedValue = this.handleToken(parsedToken) @@ -399,7 +400,9 @@ export class SchemaStream { completedValuesSinceEmission += 1 } } - parser.onValue = () => undefined + parser.onValue = () => { + hasParsedValue = true + } const emitSnapshot = (controller: TransformStreamDefaultController): void => { const snapshot = textEncoder.encode(JSON.stringify(this.schemaInstance)) @@ -439,7 +442,11 @@ export class SchemaStream { if (!parser.isEnded) { parser.end() } - if (snapshotPolicy.mode !== "chunk" && emittedRevision !== parserRevision) { + if ( + snapshotPolicy.mode !== "chunk" && + hasParsedValue && + emittedRevision !== parserRevision + ) { emitSnapshot(controller) } this.activePath = [] diff --git a/tests/iterate.test.ts b/tests/iterate.test.ts index cecf331..e02b8d2 100644 --- a/tests/iterate.test.ts +++ b/tests/iterate.test.ts @@ -38,6 +38,28 @@ describe("SchemaStream.iterate", () => { } }) + test("does not yield a schema stub when the source is empty", async () => { + const schema = z.object({ value: z.string() }) + const policies: Array = [ + undefined, + { mode: "chunk" }, + { mode: "value" }, + { bytes: 8, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + const source = new ReadableStream({ + start(controller) { + controller.close() + } + }) + const emissions = await collect(new SchemaStream(schema).iterate(source, { snapshotPolicy })) + + expect(emissions).toEqual([]) + } + }) + test("rejects invalid policies before locking readable sources", async () => { const source = new ReadableStream() const iterator = new SchemaStream(z.object({ value: z.string() })).iterate(source, { diff --git a/tests/snapshot-policy.test.ts b/tests/snapshot-policy.test.ts index cdae163..d6d8031 100644 --- a/tests/snapshot-policy.test.ts +++ b/tests/snapshot-policy.test.ts @@ -54,6 +54,25 @@ describe("snapshot policies", () => { expect(explicit).toHaveLength(chunks.length) }) + test("does not emit a schema stub when the source is empty", async () => { + const policies: Array = [ + undefined, + { mode: "chunk" }, + { mode: "value" }, + { bytes: 8, mode: "bytes" }, + { mode: "final" } + ] + + for (const snapshotPolicy of policies) { + const snapshots = await collectSnapshots({ + chunks: [], + options: { snapshotPolicy } + }) + + expect(snapshots).toEqual([]) + } + }) + test("emits at completed-value boundaries", async () => { const json = JSON.stringify({ first: "streaming", second: 2 })