-
Notifications
You must be signed in to change notification settings - Fork 24
feat: add streamedListObjects for unlimited object retrieval #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the WalkthroughThis PR adds a new streaming variant of the ListObjects API to the JavaScript SDK. It includes a new Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Client as OpenFgaClient
participant API as OpenFgaApi
participant HTTP as axios
participant Server as OpenFGA Server
participant Parser as parseNDJSONStream
User->>Client: streamedListObjects(request)
Client->>API: streamedListObjects()
API->>HTTP: GET /stores/{id}/streamed-list-objects<br/>(responseType: stream, telemetry)
HTTP->>Server: Request with streaming
Server-->>HTTP: NDJSON stream (line-delimited JSON)
HTTP-->>API: Response stream
API-->>Client: Response.data (stream)
Client->>Parser: parseNDJSONStream(stream)
loop For each line in stream
Parser->>Parser: UTF-8 decode, buffer, split on \\n
Parser->>Parser: Parse JSON object
Parser-->>Client: yield StreamedListObjectsResponse
end
Client-->>User: AsyncGenerator<StreamedListObjectsResponse>
User->>User: Iterate and process results
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~35 minutes
Possibly related issues
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #280 +/- ##
==========================================
+ Coverage 89.45% 89.56% +0.11%
==========================================
Files 24 25 +1
Lines 1299 1447 +148
Branches 234 266 +32
==========================================
+ Hits 1162 1296 +134
- Misses 81 91 +10
- Partials 56 60 +4 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
🧹 Nitpick comments (10)
apiModel.ts (1)
864-876: Clarify docstring: SDK-yielded type vs wire shape.The client parses NDJSON lines like
{ result: { object } }and yields{ object }. Consider adjusting the comment to “Element yielded by streamedListObjects” to avoid implying this is the on-the-wire shape. Otherwise the interface looks good.example/streamed-list-objects-local/README.md (1)
5-7: Align Node version and add browser note.
- Change “Node.js 18+” to match SDK minimum (e.g., “Node.js ≥16.15.0”, unless 18+ is explicitly required here).
- Add: “Not supported in browsers; uses Node Readable streams.”
Please confirm whether any example dependency requires Node 18+ specifically (e.g., APIs not in Node 16.15).
common.ts (1)
353-418: Harden streaming request: headers default, NDJSON Accept, cancellation, telemetry parity.
- Ensure
headersexists before auth header injection.- Set
Accept: application/x-ndjsonif not provided.- Allow
AbortSignalpassthrough (so callers can cancel).- Optionally record request-body attributes (parity with non-stream paths, if/when added there).
Apply this diff:
export const createStreamingRequestFunction = function (axiosArgs: RequestArgs, axiosInstance: AxiosInstance, configuration: Configuration, credentials: Credentials, methodAttributes: Record<string, string | number> = {}) { configuration.isValid(); @@ - return async (axios: AxiosInstance = axiosInstance): Promise<any> => { - await setBearerAuthToObject(axiosArgs.options.headers, credentials!); + return async (axios: AxiosInstance = axiosInstance): Promise<any> => { + // ensure headers object + axiosArgs.options.headers = axiosArgs.options.headers || {}; + await setBearerAuthToObject(axiosArgs.options.headers, credentials!); @@ - const axiosRequestArgs = { ...axiosArgs.options, responseType: "stream", url: url }; + // default NDJSON accept unless caller overrides + if (!axiosArgs.options.headers["Accept"]) { + axiosArgs.options.headers["Accept"] = "application/x-ndjson"; + } + const axiosRequestArgs = { ...axiosArgs.options, responseType: "stream", url }; @@ - attributes = TelemetryAttributes.fromResponse({ + attributes = TelemetryAttributes.fromResponse({ response, attributes, });Optional: If you add
signal?: AbortSignalto client options, pass it viaaxiosArgs.options.signal.Please confirm the server’s preferred Accept header for streamed list objects (e.g.,
application/x-ndjson). If different, we should set it accordingly.tests/helpers/nocks.ts (1)
249-264: Simulate chunked NDJSON to better exercise the parser.Current mock emits a single large chunk. Emit smaller chunks to surface boundary/chunking bugs in tests.
- return nock(basePath) - .post(`/stores/${storeId}/streamed-list-objects`) - .reply(200, () => Readable.from([ndjsonResponse]), { - "Content-Type": "application/x-ndjson" - }); + return nock(basePath) + .post(`/stores/${storeId}/streamed-list-objects`) + .reply(200, () => { + // send ~32-byte chunks to simulate real streaming + const chunks = Array.from(ndjsonResponse.matchAll(/.{1,32}/gs), m => m[0]); + return Readable.from(chunks); + }, { + "Content-Type": "application/x-ndjson" + });example/streamed-list-objects-local/streamedListObjectsLocal.mjs (1)
55-57: Ensure store cleanup in a finally block.If an earlier step throws, the store may be orphaned. Move deleteStore into finally and guard on storeId.
Example:
let storeId; try { // ... create store, set storeId, do work ... } finally { if (storeId) { await new OpenFgaClient(new ClientConfiguration({ apiUrl, storeId })).deleteStore().catch(() => {}); } }api.ts (2)
387-425: Be explicit about NDJSON in request headers.Setting Accept helps interoperability and test clarity.
- const localVarHeaderParameter = {} as any; + const localVarHeaderParameter = {} as any; + localVarHeaderParameter["Accept"] = "application/x-ndjson";
955-970: Align telemetry with other methods.Include store id and body-derived attributes for consistency.
- return createStreamingRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { - [TelemetryAttribute.FgaClientRequestMethod]: "StreamedListObjects" - }); + return createStreamingRequestFunction(localVarAxiosArgs, globalAxios, configuration, credentials, { + [TelemetryAttribute.FgaClientRequestMethod]: "StreamedListObjects", + [TelemetryAttribute.FgaClientRequestStoreId]: storeId ?? "", + ...TelemetryAttributes.fromRequestBody(body), + });example/streamed-list-objects/README.md (1)
16-22: Doc: mention building the SDK or using the published package.Examples import from ../../dist; advise building first or installing from npm.
Suggested snippet:
# From repo root pnpm install pnpm build cd example/streamed-list-objects node streamedListObjects.mjsexample/streamed-list-objects/streamedListObjects.mjs (2)
19-36: writeTuples drops remainder; handle non-multiples of 100.If quantity isn’t divisible by 100, the tail is lost.
- const chunks = Math.floor(quantity / 100); + const chunks = Math.floor(quantity / 100); + const remainder = quantity % 100; @@ for (let chunk = 0; chunk < chunks; ++chunk) { const tuples = []; for (let t = 0; t < 100; ++t) { tuples.push({ user: "user:anne", relation: "owner", object: `document:${chunk * 100 + t}` }); } await fgaClient.writeTuples(tuples); } + if (remainder) { + const tuples = []; + for (let t = 0; t < remainder; ++t) { + tuples.push({ + user: "user:anne", + relation: "owner", + object: `document:${chunks * 100 + t}` + }); + } + await fgaClient.writeTuples(tuples); + }
94-99: Ensure cleanup in finally.Move deleteStore into finally and guard on storeId to avoid orphaned stores on errors.
Example:
let storeId; try { storeId = await createStore(fgaClient); // ... } finally { if (storeId) await fgaClient.deleteStore().catch(() => {}); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
CHANGELOG.md(1 hunks)api.ts(5 hunks)apiModel.ts(1 hunks)client.ts(3 hunks)common.ts(1 hunks)example/streamed-list-objects-local/README.md(1 hunks)example/streamed-list-objects-local/streamedListObjectsLocal.mjs(1 hunks)example/streamed-list-objects/README.md(1 hunks)example/streamed-list-objects/model.json(1 hunks)example/streamed-list-objects/streamedListObjects.mjs(1 hunks)index.ts(1 hunks)streaming.ts(1 hunks)tests/helpers/nocks.ts(2 hunks)tests/streaming.test.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (8)
example/streamed-list-objects/streamedListObjects.mjs (2)
client.ts (4)
createStore(324-326)writeTuples(568-572)streamedListObjects(825-845)listObjects(797-807)example/streamed-list-objects-local/streamedListObjectsLocal.mjs (2)
model(13-29)storeId(9-9)
tests/helpers/nocks.ts (1)
tests/helpers/default-config.ts (1)
defaultConfiguration(56-56)
streaming.ts (1)
index.ts (1)
parseNDJSONStream(27-27)
client.ts (2)
apiModel.ts (1)
StreamedListObjectsResponse(869-876)streaming.ts (1)
parseNDJSONStream(96-168)
api.ts (3)
apiModel.ts (1)
ListObjectsRequest(804-847)common.ts (6)
RequestArgs(30-33)DUMMY_BASE_URL(23-23)setSearchParams(51-66)serializeDataIfNeeded(88-96)toPathString(102-104)createStreamingRequestFunction(353-418)validation.ts (1)
assertParamExists(8-12)
example/streamed-list-objects-local/streamedListObjectsLocal.mjs (1)
client.ts (2)
OpenFgaClient(233-945)ClientConfiguration(59-92)
tests/streaming.test.ts (1)
streaming.ts (1)
parseNDJSONStream(96-168)
common.ts (4)
base.ts (1)
RequestArgs(15-18)configuration.ts (1)
Configuration(58-202)telemetry/attributes.ts (1)
TelemetryAttributes(35-130)telemetry/histograms.ts (1)
TelemetryHistograms(20-32)
🔇 Additional comments (10)
client.ts (1)
24-25: Imports look correct.Also applies to: 52-53
common.ts (1)
345-347: No functional change here.tests/helpers/nocks.ts (1)
3-4: LGTM: Node stream import fits the streaming mock.api.ts (1)
1442-1454: OO wrapper wiring looks correct.index.ts (1)
27-27: LGTM: public export of parseNDJSONStream.example/streamed-list-objects/model.json (1)
1-251: LGTM: model is coherent and matches example relations.tests/streaming.test.ts (4)
14-19: LGTM! Well-organized test suite structure.The imports and test suite organization are clean and appropriate for testing the NDJSON streaming parser.
20-96: Excellent coverage of core parsing scenarios.These tests cover the fundamental NDJSON parsing cases effectively. The chunked data test (lines 48-65) is particularly valuable as it validates the buffering logic when JSON objects are split across stream chunks—a critical real-world scenario.
98-162: Comprehensive input type coverage.These tests validate the parser's flexibility with different input types (Buffer, string, async iterable). The test for JSON without a trailing newline (lines 109-117) is particularly important as it validates the final buffer flush logic. The
as anytype casts are acceptable here to test the parser's runtime behavior with various input types.
164-257: Thorough async generator protocol and cleanup testing.These tests rigorously validate the async iterator implementation, including error handling, early cancellation, buffering behavior, and proper cleanup of event listeners. The listener cleanup assertions (lines 193-195, 241-243) are particularly valuable for preventing memory leaks. While these tests exercise internal implementation details, the guarantees they provide about correctness and resource management justify their inclusion.
Updates JavaScript SDK templates to support the streaming API endpoint for unlimited object retrieval. Templates now handle streaming operations differently using vendor extension conditionals. Changes: - Add streaming.mustache template with NDJSON parser for Node.js - Update api.mustache to import createStreamingRequestFunction - Update apiInner.mustache with x-fga-streaming vendor extension logic - Uses createStreamingRequestFunction for streaming ops - Returns Promise<any> instead of PromiseResult<T> - Simplified telemetry (method name only) - Update index.mustache to export parseNDJSONStream - Update config.overrides.json with streaming file + feature flag - Add README documentation for Streamed List Objects API - Update API endpoints table with streaming endpoint Implementation: - Conditional template logic based on x-fga-streaming vendor extension - Preserves telemetry while returning raw Node.js stream - Aligned with Python SDK template patterns Dependencies: - Requires x-fga-streaming: true in OpenAPI spec (openfga/api) Related: - Fixes #76 (JavaScript SDK) - Implements openfga/js-sdk#236 - Related PR: openfga/js-sdk#280
Updates JavaScript SDK templates to support the streaming API endpoint for unlimited object retrieval. Templates now handle streaming operations differently using vendor extension conditionals. Changes: - Add streaming.mustache template with NDJSON parser for Node.js - Update api.mustache to import createStreamingRequestFunction - Update apiInner.mustache with x-fga-streaming vendor extension logic - Uses createStreamingRequestFunction for streaming ops - Returns Promise<any> instead of PromiseResult<T> - Simplified telemetry (method name only) - Update index.mustache to export parseNDJSONStream - Update config.overrides.json with streaming file + feature flag - Add README documentation for Streamed List Objects API - Update API endpoints table with streaming endpoint Implementation: - Conditional template logic based on x-fga-streaming vendor extension - Preserves telemetry while returning raw Node.js stream - Aligned with Python SDK template patterns - Fixed error propagation in async iterator adapter - Widened parseNDJSONStream type signature for better DX Dependencies: - Requires x-fga-streaming: true in OpenAPI spec (openfga/api) Related: - Fixes #76 (JavaScript SDK) - Implements openfga/js-sdk#236 - Related PR: openfga/js-sdk#280
Updates JavaScript SDK templates to support the streaming API endpoint for unlimited object retrieval. Templates now handle streaming operations differently using vendor extension conditionals. Templates Modified (7 files): - Add streaming.mustache template with NDJSON parser for Node.js - Update api.mustache to import createStreamingRequestFunction - Update apiInner.mustache with x-fga-streaming vendor extension logic - Uses createStreamingRequestFunction for streaming ops - Returns Promise<any> instead of PromiseResult<T> - Simplified telemetry (method name only) - Update index.mustache to export parseNDJSONStream - Update config.overrides.json with streaming file + feature flag - Add README_calling_api.mustache documentation for Streamed List Objects - Add README_api_endpoints.mustache table entry for streaming endpoint Implementation: - Conditional template logic based on x-fga-streaming vendor extension - Preserves telemetry while returning raw Node.js stream - Aligned with Python SDK template patterns - Fixed error propagation in async iterator adapter - Widened parseNDJSONStream type signature for better DX - Added guard to prevent onEnd processing after error state Generated SDK Verification: - ✅ streaming.ts generated with all error handling fixes - ✅ parseNDJSONStream exported from index.ts - ✅ StreamedListObjectsResponse interface in apiModel.ts -⚠️ streamedListObjects method uses regular handling (needs x-fga-streaming: true in spec) Dependencies: - Requires x-fga-streaming: true vendor extension in OpenAPI spec (openfga/api) - Without vendor extension, method is generated but uses wrong request handler Related: - Fixes #76 (JavaScript SDK) - Implements openfga/js-sdk#236 - Related PR: openfga/js-sdk#280
Adds NDJSON streaming parser template to support streamedListObjects in the JavaScript SDK. Templates provide the parsing utility; actual streaming implementation remains in custom code (client.ts, common.ts) in js-sdk repo. Templates Added/Modified (5 files): - streaming.mustache (NEW) - NDJSON parser for Node.js streams - Proper error propagation (pending promises reject on error) - onEnd guard prevents processing after error state - Widened type signature (Readable|AsyncIterable|string|Buffer) - index.mustache - Export parseNDJSONStream utility - config.overrides.json - Register streaming.mustache + supportsStreamedListObjects flag - README_calling_api.mustache - Add Streamed List Objects usage documentation - README_api_endpoints.mustache - Add endpoint to API table Architecture: - Templates generate utilities and basic API methods - Custom code in js-sdk handles actual streaming (client.ts, common.ts) - No OpenAPI spec changes required Tested: - Generated streaming.ts includes all error handling fixes - parseNDJSONStream exported correctly - Custom js-sdk code works with generated utilities Related: - Fixes #76 (JavaScript SDK) - Implements openfga/js-sdk#236 - Related PR: openfga/js-sdk#280
Adds streamedListObjects method for retrieving unlimited objects via the streaming API endpoint. Node.js-only implementation with resilient NDJSON parsing, proper error handling, and automatic resource cleanup. Requires OpenFGA server v1.2.0+ Features: - Streams beyond 1000-object limit (tested with 2000 objects) - Memory-efficient incremental results via async generators - Automatic stream cleanup prevents connection leaks - Proper error propagation through async iterators - Flexible input types (Readable|AsyncIterable|string|Buffer|Uint8Array) - Telemetry maintained through streaming request helper Implementation: - streaming.ts: NDJSON parser with robust error handling - common.ts: createStreamingRequestFunction for axios streaming - client.ts: streamedListObjects() async generator wrapper - Error handling: Pending promises reject on error, onEnd guarded - Resource management: Stream destruction in return()/throw()/finally - Type safety: Wide signature eliminates unnecessary casts Testing (153/153 tests passing): - 17 streaming tests (parsing, errors, cleanup, edge cases) - 95% coverage on streaming.ts - Live tested: 3-object and 2000-object streaming verified Examples: - example/streamed-list-objects: Full model with 2000 tuples - example/streamed-list-objects-local: Minimal local setup Related: - Fixes #236 - Parent issue: openfga/sdk-generator#76 - Related PR: openfga/sdk-generator#654 (templates)
Adds NDJSON streaming parser template to support streamedListObjects in the JavaScript SDK. Templates provide parsing utilities; actual streaming logic remains in custom code (client.ts, common.ts) maintained in js-sdk repository. Templates Added/Modified (5 files): - streaming.mustache (NEW): NDJSON parser for Node.js streams - Proper error propagation (reject pending promises on error) - onEnd guard prevents processing after error - Uint8Array handling alongside string/Buffer - Stream destruction in return()/throw() methods - Widened type signature for better DX - index.mustache: Export parseNDJSONStream utility - config.overrides.json: Register streaming + supportsStreamedListObjects flag - README_calling_api.mustache: Usage documentation - README_api_endpoints.mustache: API endpoint table entry Architecture: - Templates generate utilities (streaming.ts, exports) - Custom js-sdk code implements streaming (common.ts, client.ts) - No OpenAPI spec changes required Generated & Verified: - streaming.ts includes all error handling fixes - parseNDJSONStream exported correctly - Works with custom js-sdk streaming implementation Related: - Fixes #76 (JavaScript SDK) - Implements openfga/js-sdk#236 - Related PR: openfga/js-sdk#280
3db22bb to
0c2a0ab
Compare
example/streamed-list-objects-local/streamedListObjectsLocal.mjs
Outdated
Show resolved
Hide resolved
example/streamed-list-objects-local/streamedListObjectsLocal.mjs
Outdated
Show resolved
Hide resolved
example/streamed-list-objects-local/streamedListObjectsLocal.mjs
Outdated
Show resolved
Hide resolved
|
Can you also review @SoulPancake's PR here: openfga/go-sdk#252 Ideally both would have the same semantics, example, tests, config options, README, etc.. |
- Simplify to one example using syntax transformer - Add StreamedListObjects documentation to main README - Add 5 client integration tests (streaming, headers, errors, retry, consistency) - Simplify CHANGELOG with links to documentation - Use @openfga/syntax-transformer in example
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
Add documentation for the StreamedListObjects API to the List Objects guide. Changes: - Add Streamed List Objects section explaining streaming differences - Include Node.js and Python SDK examples - Add note about SDK availability - Add related link to StreamedListObjects API reference Related: - openfga/js-sdk#280 - openfga/sdk-generator#654 - openfga/sdk-generator#76
Switch JavaScript SDK build to use build-client-streamed target (like Python SDK). This includes the streaming endpoints in the generated SDK. Changes: - Makefile: build-client-js uses build-client-streamed instead of build-client This allows the JS SDK to include StreamedListObjects endpoint while custom code in js-sdk repo (common.ts, client.ts) implements the streaming logic. Related: - openfga/js-sdk#280 - #76
- Add detailed explanation of StreamedListObjects - Document async generator pattern - Show early break and cleanup examples - List benefits and performance considerations - Update to reflect 2000 tuples in example
| writes.push({ user: "user:anne", relation: "can_read", object: `document:${i}` }); | ||
| } | ||
| await fga.write({ writes }); | ||
| console.log(`Wrote ${writes.length} tuples`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example itself does not make a lot of sense, not sure if we are using the same in every SDK. We should not write tuples to the same relation we do list_objects (we can solve the same query with Read, which is much faster).
The model should be something like
define owner : [user]
define viewer : [user]
define can_read : owner or viewer
We can write tuples to owner/viewer and call list_objects on can_read
cc @rhamzeh
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New Example Approach Summary
The Change
Old: Write and query the same relation (direct tuples)
Write: user:anne can_read document:1-2000
Query: can_read
New: Write to base relations, query computed relation
Model:
define owner: [user]
define viewer: [user]
define can_read: owner or viewer ← COMPUTED
Write:
user:anne owner document:1-1000
user:anne viewer document:1001-2000
Query: can_read (returns all 2000 via computation)
Why
- Shows OpenFGA's value: Computed permissions from base relations
- Demonstrates streaming need: Computed relations return large result sets
- Educational: Real-world authorization pattern
- Update authorization model to show owner/viewer/can_read pattern - Write tuples to base relations (owner, viewer) - Query computed relation (can_read = owner OR viewer) - Demonstrates OpenFGA's value: derived permissions from base relations - Update CHANGELOG to be brief with link to README (per @rhamzeh feedback) - Remove OPENFGA_LIST_OBJECTS_DEADLINE from manually-written docs - Clarify no pagination limit vs server timeout in all documentation - Add detailed explanation of computed relations in example README Addresses feedback from @aaguiarz and @SoulPancake: - openfga/js-sdk#280 (comment) - Shows why StreamedListObjects is valuable for computed relations All tests passing. Example builds successfully.
- Write tuples in batches of 100 (OpenFGA write limit) - Prevents validation error when writing 2000 tuples - Matches JS SDK batching pattern - Verified working with live OpenFGA server Example successfully streams 2000 objects from computed can_read relation.
- Write tuples in batches of 100 (OpenFGA write limit) - Prevents validation error when writing 2000 tuples - Matches JS SDK batching pattern - Verified working with live OpenFGA server Example successfully streams 2000 objects from computed can_read relation.
Resolves CHANGELOG conflict: - Place streamedListObjects in Unreleased section (not yet released) - Keep conflict options feature in v0.9.1 (already released) - Brings in latest workflow updates and package changes from main All 182 tests passing. Example verified working.
- Sanitize error logging to avoid exposing config values - Handle FgaValidationError separately (log field name only) - Keep helpful ECONNREFUSED hint for connection issues - Generic error message for other errors Addresses CodeQL security finding: Clear-text logging of sensitive information All 182 tests passing. Example verified working.
- Don't log err.field (could contain sensitive field names like apiTokenIssuer) - Use fully generic validation error message - Addresses CodeQL: Clear-text logging of sensitive information All 182 tests passing. Example verified working.
Summary
Adds
streamedListObjectsmethod for retrieving unlimited objects via the streaming API. Node.js-only implementation with NDJSON parsing, error handling, and automatic resource cleanup.Requires OpenFGA server v1.2.0+
Fixes #236
Changes
streaming.tswith NDJSON parser for Node.js streamsStreamedListObjectsResponseinterface toapiModel.tsOpenFgaClient.streamedListObjects()async generator methodcreateStreamingRequestFunctiontocommon.tsfor streaming requestsparseNDJSONStreamfromindex.tsImplementation
Usage
Testing
All 153 tests passing (10/10 suites)
Related