Support transforming primitive types using a codec#73
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 27b00652df
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
This PR adds a comprehensive Transform feature to loro-mirror that enables bidirectional conversion between CRDT leaf primitives (string, number, boolean) and rich domain types (Date, BigInt, custom objects, etc.). This allows developers to work with domain types throughout their application while Loro stores JSON-serializable primitives.
Changes:
- Added
.transform()method toschema.String(),schema.Number(), andschema.Boolean()primitives - Implemented encoding (domain → CRDT) during
setState()and diff operations - Implemented decoding (CRDT → domain) during Loro event application and snapshot initialization
- Added configurable equality strategies (
reference-equality,encoded-value-equality,deep-equality, custom function) for change detection - Added comprehensive test coverage (~3,560 lines across 9 test files) covering composition, edge cases, equality strategies, error handling, and peer sync
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/src/schema/index.ts | Adds .transform() builder methods to String, Number, and Boolean schema types |
| packages/core/src/schema/types.ts | Defines TransformDefinition and EqualityStrategy types; updates type inference to return domain types |
| packages/core/src/schema/validators.ts | Updates validation to work with domain types; adds transform-specific validation |
| packages/core/src/core/utils.ts | Implements applyEncode(), applyDecode(), valuesEqual(), and decodeNestedJsonValues() helper functions |
| packages/core/src/core/mirror.ts | Integrates encoding in setState() and decoding in snapshot building; renames JSON types to MirrorState |
| packages/core/src/core/loroEventApply.ts | Applies decoding during Loro event processing to transform primitives to domain types |
| packages/core/src/core/diff.ts | Uses valuesEqual() for change detection and applyEncode() for primitive diffs |
| packages/core/tests/*.test.ts | Adds 9 comprehensive test files covering roundtrip, validation, types, equality, optional fields, error handling, edge cases, and diff operations |
| README.md | Documents the transform feature with examples and API reference |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
074c207 to
84b0a66
Compare
Enable conversion between CRDT primitives (strings, numbers, booleans) and application domain types (Date, Temporal, BigInt, custom objects). - Add TransformDefinition interface with decode/encode functions - Add EqualityStrategy for configurable diff behavior - Integrate transforms into diff, event application, and Mirror - Add comprehensive test suite (9 test files, ~2,651 lines)
98ac76b to
d96a942
Compare
# Conflicts: # packages/core/src/core/mirror.ts
# Conflicts: # packages/core/src/core/loroEventApply.ts # packages/core/src/core/mirror.ts # packages/core/src/schema/index.ts # packages/core/src/schema/resolver.ts # packages/core/tests/inferType.test-d.ts # packages/core/tests/schema-resolver.test.ts
Transform Feature Proposal for Loro Mirror
Hi there!
This PR proposes a new Transform feature for loro-mirror. It enables bidirectional conversion between CRDT leaf primitives (
String,NumberandBoolean) and rich domain types (likeTemporaltypes,BigIntor custom objects). This allows users to model their application state with rich domain types, while Loro continues to store JSON primitives. I used Zod codecs for inspiration, noting you appear to have done the same forcatchall.I imagine this could be a first step in a broader effort to support a rich schema definition in Loro Mirror similar to frameworks like Zod, but with all the incredible benefits of Loro Mirror's CRDT core. For example, you could imagine Loro Mirror supporting features like
z.tuple,z.unionandz.discriminatedUnionfor rich domain modelling. I don't think this would require any changes to Loro's core - just incremental improvements to Loro Mirror.This is unsolicited work, so I completely understand if it doesn't align with your vision. I'm happy to discuss, iterate, or accept a "no thank you."
API at a Glance
Three additions to the public API:
The transform argument has this shape:
Before / After
Type inference works automatically —
InferTypereturnsDate, notstring.Why This Matters
The core benefit: Allows users of Loro to model their application state with rich domain types, not JSON primitives.
date.getDay())stringcould be anythingDateWhy Not Just Use Selectors?
You might ask: "Why not just use React/Jotai selectors to transform on read rather than complicating Loro Mirror?"
Selectors only work one direction:
Selectors require caching at every level to avoid recomputation. Cache invalidation grows with your state tree depth.
Transforms decode once during event application.
getState()returns cached domain objects instantly — no memoization needed.Quick Examples
Date Transform
Optional Fields
Optional fields work directly with transforms — no wrapper needed:
When a field is
undefined, the transform is bypassed entirely. Theundefinedpasses through as-is.List with Transforms
Equality Strategies
The transform config has an optional
isEqualproperty that controls how Loro Mirror detects whether a transformed value has changed duringsetState(). This determines when CRDT operations are emitted."reference-equality"(default)Different reference = different value. Fast O(1) check.
Best for: Most cases. As long as a domain type is immutable or ImmerJS compatible, this will just work. However, it will emit unnecessary CRDT ops if a reference changes but the encoded value is the same (for example, two separate
Dateobjects at the same instant)."encoded-value-equality"Encodes both values and compares the primitives. Slower but avoids spurious updates.
Best for: Cases where the reference might change regularly but the value remains the same. Recommended if the encode function is fast, to minimise CRDT ops emitted.
"deep-equality"Performs deep recursive comparison of domain values.
Best for: Complex objects where you want structural equality without encoding overhead. This is rarely needed as Immer will detect changes deep within an object hierarchy and change the reference of the root.
Custom function
Full control over comparison logic.
Best for: When you can compare faster than encoding (e.g., comparing timestamps is faster than
toISOString()), or when you need domain-specific equality (e.g., comparing only anidfield on a complex object).Validation
The behaviour of validation is maintained - validation runs on the mirror state rather than the CRDT document. However, this is no longer validating a JSON object, since the mirror state is now a domain type. The validation functions,
validateUpdatesandvalidateSchema, now validate the domain type rather than the JSON that will become the CRDT type. By default, domain types are not encoded to check whether they have the correct JSON type. However, each transform can opt into this by settingvalidateEncodedTypetotrue.Additionally, transforms can define their own
validatefunction that receives the domain value and returnstruefor valid or an error message string for invalid.Implementation Details
How it works:
setState()→ encodes domain values that have changed to primitives and writes to CRDTproduce()(atomic)getState()→ returns cached state with domain objects (no decoding at read time)null/undefined→ bypass transforms entirely, pass through as-isPeer sync: Only CRDT primitives travel over the network. Each peer decodes based on its schema.
One Area of Uncertainty
The one area of the code base I didn't fully understand was the tree handling in
mirror.tsin thecontainerToMirrorStatefunction. It seems like the conversion of trees to mirror states tries to avoid multiple WASM roundtrips by lazily converting tree nodes to JSON, normalizing their shape and converting ids to cids on just the tree nodes. It appears to not transform thenode.datamap containers and any nested children they may have. I'm assuming this is a performance optimization, wheresetStateis intended to later restore the cids of nested data if they change. I needed to update this logic to recursively decode any primitives values that might be nested inside the tree (implemented asdecodeNestedJsonValues, which you can see I'm calling incontainerToMirrorState).Note also that I've renamed
containerToJsonand the associated types and functions tocontainerToMirrorStateas they are no longer strictly JSON (since they can be rich domain types like dates or BigInts).I don't understand why the following is duplicated in
loroEventApply.tsandmirror.tsbut I updated both to use the termMirrorStaterather thanJSON:Test Coverage
~3,560 lines across 9 test files covering:
Breaking Changes
None. Purely additive. Existing schemas without transforms work unchanged.
Known Limitations
nullorundefined(enforced by& {}type constraint)Feedback Welcome
This is complete and tested, but I'm open to:
Thanks for considering this. I've found it valuable in my own work and thought it might benefit other loro-mirror users.
Further Documentation
For more detail:
packages/core/src/schema/index.ts-.transform()builderpackages/core/src/schema/types.ts-TransformDefinition,EqualityStrategypackages/core/src/core/utils.ts-applyEncode(),applyDecode(),valuesEqual()packages/core/src/core/mirror.ts- Integration in setState/getStatepackages/core/src/core/loroEventApply.ts- Decode during event applicationpackages/core/src/core/diff.ts-valuesEqual()in all 5 diff functions