Skip to content

Commit 2045d5f

Browse files
bentefayzxch3n
andauthored
feat: Support transforming primitive types using a codec (#73)
* feat(core): add bidirectional transforms for domain types 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) * fix(core): tighten transform equality checks * fix(core): harden transform schema helpers * fix(core): decode ephemeral transform values * refactor(api): remove throwOnValidationError option * fix(types): reflect missing transformed startup values * refactor(schema): centralize child schema resolution * fix(core): restore schema.String overload compatibility --------- Co-authored-by: Zixuan Chen <remch183@outlook.com>
1 parent e24056d commit 2045d5f

24 files changed

Lines changed: 3881 additions & 273 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
## Public API (loro-mirror)
4343

4444
- `Mirror(options: MirrorOptions<S>)`
45-
- `doc` (required), `schema?`, `initialState?`, `validateUpdates?`, `throwOnValidationError?`, `debug?`, `checkStateConsistency?`, `inferOptions?`.
45+
- `doc` (required), `schema?`, `initialState?`, `validateUpdates?`, `debug?`, `checkStateConsistency?`, `inferOptions?`.
4646
- Methods: `getState()`, `setState(updater, options?)`, `subscribe(cb)`, `dispose()`, `checkStateConsistency()`, `getContainerIds()`.
4747
- `SetStateOptions` supports `{ tags?: string | string[] }`; subscriber metadata includes `{ source: UpdateSource; tags?: string[] }`.
4848
- `schema(definition, options?)` plus builders: `.String()`, `.Number()`, `.Boolean()`, `.Ignore()`, `.LoroMap()`, `.LoroMapRecord()`, `.LoroList()`, `.LoroMovableList()`, `.LoroText()`, `.LoroTree()`.

README.md

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ A TypeScript state management library that syncs application state with [loro-cr
1010
- 🔍 **Selective Updates**: Subscribe to specific parts of your state
1111
- 🛠️ **Developer Friendly**: Familiar API inspired by popular state management libraries
1212
- 📱 **React Integration**: Hooks and context providers for React applications
13+
- 🔀 **Transforms**: Work with rich domain types (Date, BigInt, custom objects) while Loro stores primitives
1314

1415
## Packages
1516

@@ -113,6 +114,49 @@ Loro Mirror provides a declarative schema system that enables:
113114
- `schema.Any(options?)` - Runtime-inferred value/container type for JSON-like dynamic fields
114115
- `schema.Ignore(options?)` - Field that won't sync with Loro, useful for local computed fields
115116

117+
- **Transforms** (on primitive types):
118+
119+
Primitive schemas support `.transform()` to convert between CRDT primitives and rich domain types:
120+
121+
```typescript
122+
const dateTransform = {
123+
decode: (s: string) => new Date(s),
124+
encode: (d: Date) => d.toISOString(),
125+
};
126+
127+
const taskSchema = schema({
128+
task: schema.LoroMap({
129+
title: schema.String(),
130+
dueDate: schema.String().transform(dateTransform), // Type infers as Date
131+
}),
132+
});
133+
134+
// Now you can use Date objects directly
135+
mirror.setState({ task: { title: "Review PR", dueDate: new Date() } });
136+
mirror.getState().task.dueDate.getTime(); // Already a Date
137+
```
138+
139+
The transform definition has this shape:
140+
141+
```typescript
142+
interface TransformDefinition<CRDTType, DomainType> {
143+
decode: (value: CRDTType & {}) => DomainType & {};
144+
encode: (value: DomainType & {}) => CRDTType & {};
145+
validate?: (value: DomainType & {}) => boolean | string;
146+
isEqual?:
147+
| "reference-equality"
148+
| "encoded-value-equality"
149+
| "deep-equality"
150+
| ((a: DomainType, b: DomainType) => boolean);
151+
}
152+
```
153+
154+
**Note:** The encode and decode functions will never receive `null` or `undefined` values and must not return them. Nullish values are handled before transforms are applied.
155+
156+
**Important:** `DomainType` should be immutable or [supported by Immer](https://immerjs.github.io/immer/complex-objects/), otherwise changes to transformed values may not be detected and converted to CRDT operations. Never mutate `DomainType` instances outside of Loro Mirror's `setState` function.
157+
158+
For optional fields, just use the transform directly - the type automatically includes `| undefined`:
159+
116160
`schema.Any` options:
117161

118162
- `defaultLoroText?: boolean` (defaults to `false` for `Any` when omitted)
@@ -334,9 +378,9 @@ For detailed documentation, see the README files in each package:
334378

335379
Loro Mirror uses a typed schema to map your app state to Loro containers. Common schema constructors:
336380

337-
- `schema.String(options?)`: string
338-
- `schema.Number(options?)`: number
339-
- `schema.Boolean(options?)`: boolean
381+
- `schema.String(options?)`: string (supports `.transform()` for domain types)
382+
- `schema.Number(options?)`: number (supports `.transform()` for domain types)
383+
- `schema.Boolean(options?)`: boolean (supports `.transform()` for domain types)
340384
- `schema.Any(options?)`: JSON-like unknown (runtime-inferred)
341385
- `schema.Ignore(options?)`: exclude from sync (app-only)
342386
- `schema.LoroText(options?)`: rich text (`LoroText`)
@@ -363,7 +407,6 @@ const mySchema = schema({ outline: schema.LoroTree(node) });
363407
- **`schema`**: root schemaoptional but recommended for strong typing and validation.
364408
- **`initialState`**: partial statemerged with schema defaults and current doc JSON.
365409
- **`validateUpdates`**: boolean (default `true`) – validate new state against schema.
366-
- **`throwOnValidationError`**: boolean (default `false`) – throw on invalid updates.
367410
- **`debug`**: boolean (default `false`) – log diffs and applied changes.
368411
- **`checkStateConsistency`**: boolean (default `false`) – after non-ephemeral `setState` calls, assert the doc-backed base state equals the normalized `LoroDoc` snapshot.
369412
- **`inferOptions`**: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }`influence container-type inference when inserting containers from plain values.

packages/core/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,11 @@ Trees are advanced usage; see Advanced: Trees at the end.
7070

7171
### Mirror
7272

73-
- Constructor: `new Mirror({ doc, schema?, initialState?, validateUpdates?=true, throwOnValidationError?=false, debug?=false, checkStateConsistency?=false, inferOptions? })`
73+
- Constructor: `new Mirror({ doc, schema?, initialState?, validateUpdates?=true, debug?=false, checkStateConsistency?=false, inferOptions? })`
7474
- doc: LoroDoc to sync with
7575
- schema: Root schema; enables validation and typed defaults
7676
- initialState: Shallow-merged over schema defaults and current doc JSON
7777
- validateUpdates: Validate on `setState`
78-
- throwOnValidationError: Throw if validation fails (default false)
7978
- debug: Verbose logging
8079
- checkStateConsistency: Extra runtime check that the doc-backed base state still matches `toNormalizedJson(doc)` after non-ephemeral `setState` updates
8180
- inferOptions: `{ defaultLoroText?: boolean; defaultMovableList?: boolean }` for container inference when schema is missing

packages/core/src/core/diff.ts

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SchemaType,
2323
type InferContainerOptions,
2424
} from "../schema/index.js";
25+
import { getMapFieldSchema } from "../schema/resolver.js";
2526
import { ChangeKinds, type Change } from "./mirror.js";
2627
import { CID_KEY } from "../constants.js";
2728

@@ -41,8 +42,10 @@ import {
4142
isArrayLike,
4243
isTreeID,
4344
defineCidProperty,
45+
valuesEqual,
46+
applyEncode,
47+
hasTransform,
4448
} from "./utils.js";
45-
import { getMapFieldSchema } from "../schema/resolver.js";
4649

4750
/**
4851
* Finds the longest increasing subsequence of a sequence of numbers
@@ -628,6 +631,7 @@ export function diffMovableList<S extends ArrayLike>(
628631
}
629632

630633
// 4) Insertions (items only in new state)
634+
const itemSchema = schema?.itemSchema;
631635
for (const [newIndex, item] of newState.entries()) {
632636
const id = idSelector(item);
633637
if (!id || !oldMap.has(id)) {
@@ -640,7 +644,7 @@ export function diffMovableList<S extends ArrayLike>(
640644
kind: "insert",
641645
},
642646
true,
643-
schema?.itemSchema,
647+
itemSchema,
644648
inferOptions,
645649
),
646650
);
@@ -649,7 +653,7 @@ export function diffMovableList<S extends ArrayLike>(
649653

650654
// 5) Updates (for items present in both states)
651655
for (const info of common) {
652-
if (deepEqual(info.oldItem, info.newItem)) continue;
656+
if (valuesEqual(itemSchema, info.oldItem, info.newItem, "deep-equality")) continue;
653657

654658
const movableList = doc.getMovableList(containerId);
655659
const currentItem = movableList.get(info.oldIndex);
@@ -659,7 +663,7 @@ export function diffMovableList<S extends ArrayLike>(
659663
info.oldItem,
660664
info.newItem,
661665
currentItem.id,
662-
schema?.itemSchema,
666+
itemSchema,
663667
inferOptions,
664668
);
665669
changes.push(...nested);
@@ -673,7 +677,7 @@ export function diffMovableList<S extends ArrayLike>(
673677
kind: "set",
674678
},
675679
true,
676-
schema?.itemSchema,
680+
itemSchema,
677681
inferOptions,
678682
),
679683
);
@@ -761,6 +765,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
761765
}
762766

763767
// 2) Insertions (items that are new or moved).
768+
const itemSchema = schema?.itemSchema;
764769
for (const [newIndex, item] of newState.entries()) {
765770
const id = idSelector(item);
766771
if (!id || !keepIdSet.has(id)) {
@@ -773,7 +778,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
773778
kind: "insert",
774779
},
775780
useContainer,
776-
schema?.itemSchema,
781+
itemSchema,
777782
inferOptions,
778783
),
779784
);
@@ -790,7 +795,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
790795

791796
const oldItem = oldInfo.item;
792797
const newItem = newInfo.item;
793-
if (deepEqual(oldItem, newItem)) continue;
798+
if (valuesEqual(itemSchema, oldItem, newItem, "deep-equality")) continue;
794799

795800
const itemOnLoro = list.get(oldInfo.index);
796801
if (isContainer(itemOnLoro)) {
@@ -800,7 +805,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
800805
oldItem,
801806
newItem,
802807
itemOnLoro.id,
803-
schema?.itemSchema,
808+
itemSchema,
804809
inferOptions,
805810
),
806811
);
@@ -820,7 +825,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
820825
kind: "insert",
821826
},
822827
useContainer,
823-
schema?.itemSchema,
828+
itemSchema,
824829
inferOptions,
825830
),
826831
);
@@ -861,13 +866,14 @@ export function diffList<S extends ArrayLike>(
861866
const oldLen = oldState.length;
862867
const newLen = newState.length;
863868
const list = doc.getList(containerId);
869+
const itemSchema = schema?.itemSchema;
864870

865871
// Find common prefix
866872
let start = 0;
867873
while (
868874
start < oldLen &&
869875
start < newLen &&
870-
oldState[start] === newState[start]
876+
valuesEqual(itemSchema, oldState[start], newState[start], "reference-equality")
871877
) {
872878
start++;
873879
}
@@ -877,7 +883,7 @@ export function diffList<S extends ArrayLike>(
877883
while (
878884
suffix < oldLen - start &&
879885
suffix < newLen - start &&
880-
oldState[oldLen - 1 - suffix] === newState[newLen - 1 - suffix]
886+
valuesEqual(itemSchema, oldState[oldLen - 1 - suffix], newState[newLen - 1 - suffix], "reference-equality")
881887
) {
882888
suffix++;
883889
}
@@ -889,7 +895,7 @@ export function diffList<S extends ArrayLike>(
889895
const overlap = Math.min(oldBlockLen, newBlockLen);
890896
for (let j = 0; j < overlap; j++) {
891897
const i = start + j;
892-
if (oldState[i] === newState[i]) continue;
898+
if (valuesEqual(itemSchema, oldState[i], newState[i], "reference-equality")) continue;
893899

894900
const itemOnLoro = list.get(i);
895901
if (isContainer(itemOnLoro)) {
@@ -898,7 +904,7 @@ export function diffList<S extends ArrayLike>(
898904
oldState[i],
899905
newState[i],
900906
itemOnLoro.id,
901-
schema?.itemSchema,
907+
itemSchema,
902908
inferOptions,
903909
);
904910
changes.push(...nestedChanges);
@@ -918,7 +924,7 @@ export function diffList<S extends ArrayLike>(
918924
kind: "insert",
919925
},
920926
true,
921-
schema?.itemSchema,
927+
itemSchema,
922928
inferOptions,
923929
),
924930
);
@@ -948,7 +954,7 @@ export function diffList<S extends ArrayLike>(
948954
kind: "insert",
949955
},
950956
true,
951-
schema?.itemSchema,
957+
itemSchema,
952958
inferOptions,
953959
),
954960
);
@@ -1115,13 +1121,14 @@ export function diffMovableListByIndex(
11151121
const oldLen = oldState.length;
11161122
const newLen = newState.length;
11171123
const list = doc.getMovableList(containerId);
1124+
const itemSchema = schema?.itemSchema;
11181125

11191126
// Find common prefix
11201127
let start = 0;
11211128
while (
11221129
start < oldLen &&
11231130
start < newLen &&
1124-
oldState[start] === newState[start]
1131+
valuesEqual(itemSchema, oldState[start], newState[start], "reference-equality")
11251132
) {
11261133
start++;
11271134
}
@@ -1131,7 +1138,7 @@ export function diffMovableListByIndex(
11311138
while (
11321139
suffix < oldLen - start &&
11331140
suffix < newLen - start &&
1134-
oldState[oldLen - 1 - suffix] === newState[newLen - 1 - suffix]
1141+
valuesEqual(itemSchema, oldState[oldLen - 1 - suffix], newState[newLen - 1 - suffix], "reference-equality")
11351142
) {
11361143
suffix++;
11371144
}
@@ -1143,7 +1150,7 @@ export function diffMovableListByIndex(
11431150
const overlap = Math.min(oldBlockLen, newBlockLen);
11441151
for (let j = 0; j < overlap; j++) {
11451152
const i = start + j;
1146-
if (oldState[i] === newState[i]) continue;
1153+
if (valuesEqual(itemSchema, oldState[i], newState[i], "reference-equality")) continue;
11471154

11481155
const itemOnLoro = list.get(i);
11491156
const next = newState[i];
@@ -1157,7 +1164,7 @@ export function diffMovableListByIndex(
11571164
oldState[i],
11581165
next,
11591166
itemOnLoro.id,
1160-
schema?.itemSchema,
1167+
itemSchema,
11611168
inferOptions,
11621169
),
11631170
);
@@ -1174,7 +1181,7 @@ export function diffMovableListByIndex(
11741181
kind: "set",
11751182
},
11761183
true,
1177-
schema?.itemSchema,
1184+
itemSchema,
11781185
inferOptions,
11791186
),
11801187
);
@@ -1203,7 +1210,7 @@ export function diffMovableListByIndex(
12031210
kind: "insert",
12041211
},
12051212
true,
1206-
schema?.itemSchema,
1213+
itemSchema,
12071214
inferOptions,
12081215
),
12091216
);
@@ -1301,8 +1308,10 @@ export function diffMap<S extends ObjectLike>(
13011308
inferOptions,
13021309
);
13031310
let containerType =
1304-
childSchema?.getContainerType() ??
1305-
tryInferContainerType(newItem, childInferOptions);
1311+
hasTransform(childSchema)
1312+
? undefined
1313+
: (childSchema?.getContainerType() ??
1314+
tryInferContainerType(newItem, childInferOptions));
13061315
if (
13071316
childSchema?.getContainerType() &&
13081317
containerType &&
@@ -1336,7 +1345,7 @@ export function diffMap<S extends ObjectLike>(
13361345
changes.push({
13371346
container: containerId,
13381347
key,
1339-
value: newItem,
1348+
value: applyEncode(childSchema, newItem),
13401349
kind: "insert",
13411350
});
13421351
}
@@ -1421,15 +1430,19 @@ export function diffMap<S extends ObjectLike>(
14211430
),
14221431
);
14231432
}
1424-
// The type of the child has changed
1425-
// Either it was previously a container and now it's not
1426-
// or it was not a container and now it is
14271433
} else {
1434+
if (valuesEqual(childSchema, oldItem, newItem, "reference-equality")) {
1435+
continue;
1436+
}
1437+
1438+
// The type or value of the child has changed
1439+
// Either the value has changed, or it was previously a container and now it's not,
1440+
// or it was not a container and now it is
14281441
changes.push(
14291442
insertChildToMap(
14301443
containerId,
14311444
key,
1432-
newStateObj[key],
1445+
applyEncode(childSchema, newItem),
14331446
applySchemaToInferOptions(childSchema, inferOptions),
14341447
),
14351448
);

0 commit comments

Comments
 (0)