Skip to content

Commit 84b0a66

Browse files
committed
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)
1 parent e4b060d commit 84b0a66

14 files changed

Lines changed: 3513 additions & 193 deletions

README.md

Lines changed: 47 additions & 3 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`)

packages/core/src/core/diff.ts

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ import {
4242
isArrayLike,
4343
isTreeID,
4444
defineCidProperty,
45+
valuesEqual,
46+
applyEncode,
47+
hasTransform,
4548
} from "./utils.js";
4649

4750
/**
@@ -655,6 +658,7 @@ export function diffMovableList<S extends ArrayLike>(
655658
}
656659

657660
// 4) Insertions (items only in new state)
661+
const itemSchema = schema?.itemSchema;
658662
for (const [newIndex, item] of newState.entries()) {
659663
const id = idSelector(item);
660664
if (!id || !oldMap.has(id)) {
@@ -667,7 +671,7 @@ export function diffMovableList<S extends ArrayLike>(
667671
kind: "insert",
668672
},
669673
true,
670-
schema?.itemSchema,
674+
itemSchema,
671675
inferOptions,
672676
),
673677
);
@@ -676,7 +680,7 @@ export function diffMovableList<S extends ArrayLike>(
676680

677681
// 5) Updates (for items present in both states)
678682
for (const info of common) {
679-
if (deepEqual(info.oldItem, info.newItem)) continue;
683+
if (valuesEqual(itemSchema, info.oldItem, info.newItem, "deep-equality")) continue;
680684

681685
const movableList = doc.getMovableList(containerId);
682686
const currentItem = movableList.get(info.oldIndex);
@@ -686,7 +690,7 @@ export function diffMovableList<S extends ArrayLike>(
686690
info.oldItem,
687691
info.newItem,
688692
currentItem.id,
689-
schema?.itemSchema,
693+
itemSchema,
690694
inferOptions,
691695
);
692696
changes.push(...nested);
@@ -700,7 +704,7 @@ export function diffMovableList<S extends ArrayLike>(
700704
kind: "set",
701705
},
702706
true,
703-
schema?.itemSchema,
707+
itemSchema,
704708
inferOptions,
705709
),
706710
);
@@ -788,6 +792,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
788792
}
789793

790794
// 2) Insertions (items that are new or moved).
795+
const itemSchema = schema?.itemSchema;
791796
for (const [newIndex, item] of newState.entries()) {
792797
const id = idSelector(item);
793798
if (!id || !keepIdSet.has(id)) {
@@ -800,7 +805,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
800805
kind: "insert",
801806
},
802807
useContainer,
803-
schema?.itemSchema,
808+
itemSchema,
804809
inferOptions,
805810
),
806811
);
@@ -817,7 +822,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
817822

818823
const oldItem = oldInfo.item;
819824
const newItem = newInfo.item;
820-
if (deepEqual(oldItem, newItem)) continue;
825+
if (valuesEqual(itemSchema, oldItem, newItem, "deep-equality")) continue;
821826

822827
const itemOnLoro = list.get(oldInfo.index);
823828
if (isContainer(itemOnLoro)) {
@@ -827,7 +832,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
827832
oldItem,
828833
newItem,
829834
itemOnLoro.id,
830-
schema?.itemSchema,
835+
itemSchema,
831836
inferOptions,
832837
),
833838
);
@@ -847,7 +852,7 @@ export function diffListWithIdSelector<S extends ArrayLike>(
847852
kind: "insert",
848853
},
849854
useContainer,
850-
schema?.itemSchema,
855+
itemSchema,
851856
inferOptions,
852857
),
853858
);
@@ -888,13 +893,14 @@ export function diffList<S extends ArrayLike>(
888893
const oldLen = oldState.length;
889894
const newLen = newState.length;
890895
const list = doc.getList(containerId);
896+
const itemSchema = schema?.itemSchema;
891897

892898
// Find common prefix
893899
let start = 0;
894900
while (
895901
start < oldLen &&
896902
start < newLen &&
897-
oldState[start] === newState[start]
903+
valuesEqual(itemSchema, oldState[start], newState[start], "reference-equality")
898904
) {
899905
start++;
900906
}
@@ -904,7 +910,7 @@ export function diffList<S extends ArrayLike>(
904910
while (
905911
suffix < oldLen - start &&
906912
suffix < newLen - start &&
907-
oldState[oldLen - 1 - suffix] === newState[newLen - 1 - suffix]
913+
valuesEqual(itemSchema, oldState[oldLen - 1 - suffix], newState[newLen - 1 - suffix], "reference-equality")
908914
) {
909915
suffix++;
910916
}
@@ -916,7 +922,7 @@ export function diffList<S extends ArrayLike>(
916922
const overlap = Math.min(oldBlockLen, newBlockLen);
917923
for (let j = 0; j < overlap; j++) {
918924
const i = start + j;
919-
if (oldState[i] === newState[i]) continue;
925+
if (valuesEqual(itemSchema, oldState[i], newState[i], "reference-equality")) continue;
920926

921927
const itemOnLoro = list.get(i);
922928
if (isContainer(itemOnLoro)) {
@@ -925,7 +931,7 @@ export function diffList<S extends ArrayLike>(
925931
oldState[i],
926932
newState[i],
927933
itemOnLoro.id,
928-
schema?.itemSchema,
934+
itemSchema,
929935
inferOptions,
930936
);
931937
changes.push(...nestedChanges);
@@ -945,7 +951,7 @@ export function diffList<S extends ArrayLike>(
945951
kind: "insert",
946952
},
947953
true,
948-
schema?.itemSchema,
954+
itemSchema,
949955
inferOptions,
950956
),
951957
);
@@ -975,7 +981,7 @@ export function diffList<S extends ArrayLike>(
975981
kind: "insert",
976982
},
977983
true,
978-
schema?.itemSchema,
984+
itemSchema,
979985
inferOptions,
980986
),
981987
);
@@ -1142,13 +1148,14 @@ export function diffMovableListByIndex(
11421148
const oldLen = oldState.length;
11431149
const newLen = newState.length;
11441150
const list = doc.getMovableList(containerId);
1151+
const itemSchema = schema?.itemSchema;
11451152

11461153
// Find common prefix
11471154
let start = 0;
11481155
while (
11491156
start < oldLen &&
11501157
start < newLen &&
1151-
oldState[start] === newState[start]
1158+
valuesEqual(itemSchema, oldState[start], newState[start], "reference-equality")
11521159
) {
11531160
start++;
11541161
}
@@ -1158,7 +1165,7 @@ export function diffMovableListByIndex(
11581165
while (
11591166
suffix < oldLen - start &&
11601167
suffix < newLen - start &&
1161-
oldState[oldLen - 1 - suffix] === newState[newLen - 1 - suffix]
1168+
valuesEqual(itemSchema, oldState[oldLen - 1 - suffix], newState[newLen - 1 - suffix], "reference-equality")
11621169
) {
11631170
suffix++;
11641171
}
@@ -1170,7 +1177,7 @@ export function diffMovableListByIndex(
11701177
const overlap = Math.min(oldBlockLen, newBlockLen);
11711178
for (let j = 0; j < overlap; j++) {
11721179
const i = start + j;
1173-
if (oldState[i] === newState[i]) continue;
1180+
if (valuesEqual(itemSchema, oldState[i], newState[i], "reference-equality")) continue;
11741181

11751182
const itemOnLoro = list.get(i);
11761183
const next = newState[i];
@@ -1184,7 +1191,7 @@ export function diffMovableListByIndex(
11841191
oldState[i],
11851192
next,
11861193
itemOnLoro.id,
1187-
schema?.itemSchema,
1194+
itemSchema,
11881195
inferOptions,
11891196
),
11901197
);
@@ -1201,7 +1208,7 @@ export function diffMovableListByIndex(
12011208
kind: "set",
12021209
},
12031210
true,
1204-
schema?.itemSchema,
1211+
itemSchema,
12051212
inferOptions,
12061213
),
12071214
);
@@ -1230,7 +1237,7 @@ export function diffMovableListByIndex(
12301237
kind: "insert",
12311238
},
12321239
true,
1233-
schema?.itemSchema,
1240+
itemSchema,
12341241
inferOptions,
12351242
),
12361243
);
@@ -1358,8 +1365,10 @@ export function diffMap<S extends ObjectLike>(
13581365
inferOptions,
13591366
);
13601367
let containerType =
1361-
childSchema?.getContainerType() ??
1362-
tryInferContainerType(newItem, childInferOptions);
1368+
hasTransform(childSchema)
1369+
? undefined
1370+
: (childSchema?.getContainerType() ??
1371+
tryInferContainerType(newItem, childInferOptions));
13631372
if (
13641373
childSchema?.getContainerType() &&
13651374
containerType &&
@@ -1393,7 +1402,7 @@ export function diffMap<S extends ObjectLike>(
13931402
changes.push({
13941403
container: containerId,
13951404
key,
1396-
value: newItem,
1405+
value: applyEncode(childSchema, newItem),
13971406
kind: "insert",
13981407
});
13991408
}
@@ -1478,15 +1487,19 @@ export function diffMap<S extends ObjectLike>(
14781487
),
14791488
);
14801489
}
1481-
// The type of the child has changed
1482-
// Either it was previously a container and now it's not
1483-
// or it was not a container and now it is
14841490
} else {
1491+
if (valuesEqual(childSchema, oldItem, newItem, "reference-equality")) {
1492+
continue;
1493+
}
1494+
1495+
// The type or value of the child has changed
1496+
// Either the value has changed, or it was previously a container and now it's not,
1497+
// or it was not a container and now it is
14851498
changes.push(
14861499
insertChildToMap(
14871500
containerId,
14881501
key,
1489-
newStateObj[key],
1502+
applyEncode(childSchema, newItem),
14901503
applySchemaToInferOptions(childSchema, inferOptions),
14911504
),
14921505
);

0 commit comments

Comments
 (0)