Skip to content

Commit 0e06194

Browse files
authored
Merge pull request #12 from loro-dev/feat-typed-string
feat: default required is true, add custom string type
2 parents 8cb7d13 + 59cfcac commit 0e06194

File tree

8 files changed

+265
-87
lines changed

8 files changed

+265
-87
lines changed

README.md

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ import { schema, createStore } from "loro-mirror";
5050
const todoSchema = schema({
5151
todos: schema.LoroList(
5252
schema.LoroMap({
53-
id: schema.String({ required: true }),
54-
text: schema.String({ required: true }),
53+
id: schema.String(),
54+
text: schema.String(),
5555
completed: schema.Boolean({ defaultValue: false }),
5656
}),
5757
),
@@ -95,6 +95,126 @@ store.subscribe((state) => {
9595
});
9696
```
9797

98+
### Schema Definition
99+
100+
Loro Mirror provides a declarative schema system that enables:
101+
102+
- **Type Inference**: Automatically infer TypeScript types for your application state from the schema
103+
- **Runtime Validation**: Validate data structure and types during `setState` operations or synchronization
104+
- **Default Value Generation**: Generate sensible default values based on the schema definition
105+
106+
#### Core Concepts
107+
108+
- **Root Schema**: The root object defined via `schema({...})`, containing only Loro container types (Map/List/Text/MovableList).
109+
- **Field Schema**: A combination of primitive types (string, number, boolean), ignore fields, and Loro containers.
110+
- **Schema Options (`SchemaOptions`)**:
111+
- **`required?: boolean`**: Whether the field is required (default: `true`).
112+
- **`defaultValue?: unknown`**: Default value for the field.
113+
- **`description?: string`**: Description of the field.
114+
- **`validate?: (value) => boolean | string`**: Custom validation function. Return `true` for valid values, or a string as error message for invalid ones.
115+
116+
#### Schema Definition API
117+
118+
- **Primitive Types**:
119+
- `schema.String<T extends string = string>(options?)` - String type with optional generic constraint
120+
- `schema.Number(options?)` - Number type
121+
- `schema.Boolean(options?)` - Boolean type
122+
- `schema.Ignore(options?)` - Field that won't sync with Loro, useful for local computed fields
123+
124+
- **Container Types**:
125+
- `schema.LoroMap(definition, options?)` - Object container that can nest arbitrary field schemas
126+
- Supports dynamic key-value definition with `catchall`: `schema.LoroMap({...}).catchall(valueSchema)`
127+
- `schema.LoroMapRecord(valueSchema, options?)` - Equivalent to `LoroMap({}).catchall(valueSchema)` for homogeneous maps
128+
- `schema.LoroList(itemSchema, idSelector?, options?)` - Ordered list container
129+
- Providing an `idSelector` (e.g., `(item) => item.id`) enables minimal add/remove/update/move diffs
130+
- `schema.LoroMovableList(itemSchema, idSelector, options?)` - List with native move operations, requires an `idSelector`
131+
- `schema.LoroText(options?)` - Collaborative text editing container
132+
133+
#### Type Inference
134+
135+
Automatically derive strongly-typed state from your schema:
136+
137+
```ts
138+
import { schema } from "loro-mirror";
139+
140+
type UserId = string & { __brand: "userId" }
141+
const appSchema = schema({
142+
user: schema.LoroMap({
143+
id: schema.String<UserId>(),
144+
name: schema.String(),
145+
age: schema.Number({ required: false }),
146+
}),
147+
tags: schema.LoroList(schema.String()),
148+
});
149+
150+
// Inferred state type:
151+
// type AppState = {
152+
// user: { id: UserId; name: string; age: number | undefined };
153+
// tags: string[];
154+
// }
155+
type AppState = InferType<typeof appSchema>;
156+
```
157+
158+
> **Note**: If you need optional custom string types like `{ id?: UserId }`, you currently need to explicitly define it as `schema.String<UserId>({ required: false })`
159+
160+
For `LoroMap` with dynamic key-value pairs:
161+
162+
```ts
163+
const mapWithCatchall = schema.LoroMap({ fixed: schema.Number() }).catchall(schema.String());
164+
// Type: { fixed: number } & { [k: string]: string }
165+
166+
const record = schema.LoroMapRecord(schema.Boolean());
167+
// Type: { [k: string]: boolean }
168+
```
169+
170+
When a field has `required: false`, the corresponding type becomes optional (union with `undefined`).
171+
172+
#### Default Values & Creation
173+
174+
- Explicitly specified `defaultValue` takes the highest precedence.
175+
- Built-in defaults for fields without `defaultValue` and `required: true`:
176+
- **String / LoroText**`""`
177+
- **Number**`0`
178+
- **Boolean**`false`
179+
- **LoroList**`[]`
180+
- **LoroMap / Root** → Recursively aggregated defaults from child fields
181+
182+
#### Runtime Validation
183+
184+
`Mirror` validates against the schema when `validateUpdates` is enabled (default: `true`). You can also validate directly:
185+
186+
```ts
187+
import { validateSchema } from "loro-mirror";
188+
189+
const result = validateSchema(appSchema, {
190+
user: { id: "u1", name: "Alice", age: 18 },
191+
tags: ["a", "b"],
192+
});
193+
// result = { valid: boolean; errors?: string[] }
194+
```
195+
196+
#### Lists & Movement
197+
198+
- `LoroList(item, idSelector?)`: Providing an `idSelector` enables more stable add/remove/update/move diffs; otherwise uses index-based comparison.
199+
- `LoroMovableList(item, idSelector)`: Native move operations (preserves element identity), ideal for drag-and-drop scenarios.
200+
201+
```ts
202+
const todoSchema = schema({
203+
todos: schema.LoroMovableList(
204+
schema.LoroMap({
205+
id: schema.String(),
206+
text: schema.String(),
207+
completed: schema.Boolean({ defaultValue: false }),
208+
}),
209+
(t) => t.id,
210+
),
211+
});
212+
```
213+
214+
#### Ignored Fields
215+
216+
- Fields defined with `schema.Ignore()` won't sync with Loro, commonly used for derived/cached fields. Runtime validation always passes for these fields.
217+
98218
### React Usage
99219

100220
```tsx

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,4 @@
3737
"loro-crdt": "^1.5.12",
3838
"typescript": "^5.3.3"
3939
}
40-
}
40+
}

packages/core/src/schema/index.ts

Lines changed: 53 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,21 @@
44
* This module provides utilities to define schemas that map between JavaScript types and Loro CRDT types.
55
*/
66
import {
7+
BooleanSchemaType,
78
ContainerSchemaType,
9+
IgnoreSchemaType,
810
LoroListSchema,
911
LoroMapSchema,
1012
LoroMapSchemaWithCatchall,
1113
LoroMovableListSchema,
1214
LoroTextSchemaType,
15+
NumberSchemaType,
1316
RootSchemaDefinition,
1417
RootSchemaType,
1518
SchemaDefinition,
1619
SchemaOptions,
1720
SchemaType,
21+
StringSchemaType,
1822
} from "./types";
1923

2024
export * from "./types";
@@ -23,87 +27,87 @@ export * from "./validators";
2327
/**
2428
* Create a schema definition
2529
*/
26-
export function schema<T extends Record<string, ContainerSchemaType>>(
30+
export function schema<T extends Record<string, ContainerSchemaType>, O extends SchemaOptions = {}>(
2731
definition: RootSchemaDefinition<T>,
28-
options?: SchemaOptions,
29-
): RootSchemaType<T> {
32+
options?: O,
33+
): RootSchemaType<T> & { options: O } {
3034
return {
3135
type: "schema" as const,
3236
definition,
33-
options: options || {},
37+
options: options || ({} as O),
3438
getContainerType() {
3539
return "Map";
3640
},
37-
};
41+
} as RootSchemaType<T> & { options: O };
3842
}
3943

4044
/**
4145
* Define a string field
4246
*/
43-
schema.String = function (options?: SchemaOptions) {
47+
schema.String = function <T extends string = string, O extends SchemaOptions = {}>(options?: O) {
4448
return {
4549
type: "string" as const,
46-
options: options || {},
47-
getContainerType() {
48-
return null; // Primitive type, no container
50+
options: (options || {}) as O,
51+
getContainerType: () => {
52+
return null;
4953
},
50-
};
54+
} as StringSchemaType<T> & { options: O };
5155
};
5256

5357
/**
5458
* Define a number field
5559
*/
56-
schema.Number = function (options?: SchemaOptions) {
60+
schema.Number = function <O extends SchemaOptions = {}>(options?: O) {
5761
return {
5862
type: "number" as const,
59-
options: options || {},
60-
getContainerType() {
63+
options: options || ({} as O),
64+
getContainerType: () => {
6165
return null; // Primitive type, no container
6266
},
63-
};
67+
} as NumberSchemaType & { options: O };
6468
};
6569

6670
/**
6771
* Define a boolean field
6872
*/
69-
schema.Boolean = function (options?: SchemaOptions) {
73+
schema.Boolean = function <O extends SchemaOptions = {}>(options?: O) {
7074
return {
7175
type: "boolean" as const,
72-
options: options || {},
73-
getContainerType() {
76+
options: options || ({} as O),
77+
getContainerType: () => {
7478
return null; // Primitive type, no container
7579
},
76-
};
80+
} as BooleanSchemaType & { options: O };
7781
};
7882

7983
/**
8084
* Define a field to be ignored (not synced with Loro)
8185
*/
82-
schema.Ignore = function (options?: SchemaOptions) {
86+
schema.Ignore = function <O extends SchemaOptions = {}>(options?: O) {
8387
return {
8488
type: "ignore" as const,
85-
options: options || {},
86-
getContainerType() {
89+
options: options || ({} as O),
90+
getContainerType: () => {
8791
return null;
8892
},
89-
};
93+
} as IgnoreSchemaType & { options: O };
9094
};
9195

9296
/**
9397
* Define a Loro map
9498
*/
95-
schema.LoroMap = function <T extends Record<string, SchemaType>>(
99+
schema.LoroMap = function <T extends Record<string, SchemaType> = {}, O extends SchemaOptions = {}>(
96100
definition: SchemaDefinition<T>,
97-
options?: SchemaOptions,
98-
): LoroMapSchema<T> & { catchall: <C extends SchemaType>(catchallSchema: C) => LoroMapSchemaWithCatchall<T, C> } {
101+
options?: O,
102+
): LoroMapSchema<T> & { options: O } & { catchall: <C extends SchemaType>(catchallSchema: C) => LoroMapSchemaWithCatchall<T, C> } {
99103
const baseSchema = {
100104
type: "loro-map" as const,
101105
definition,
102-
options: options || {},
106+
options: options || ({} as O),
103107
getContainerType: () => {
104108
return "Map";
105109
},
106-
} as LoroMapSchema<T>;
110+
} as LoroMapSchema<T> & { options: O };
107111

108112
// Add catchall method like zod
109113
const schemaWithCatchall = {
@@ -123,79 +127,74 @@ schema.LoroMap = function <T extends Record<string, SchemaType>>(
123127
}
124128
};
125129

126-
return schemaWithCatchall as LoroMapSchema<T> & { catchall: <C extends SchemaType>(catchallSchema: C) => LoroMapSchemaWithCatchall<T, C> };
130+
return schemaWithCatchall as LoroMapSchema<T> & { options: O } & { catchall: <C extends SchemaType>(catchallSchema: C) => LoroMapSchemaWithCatchall<T, C> };
127131
};
128132

129133
/**
130134
* Create a dynamic record schema (like zod's z.record)
131135
*/
132-
schema.LoroMapRecord = function <T extends SchemaType>(
136+
schema.LoroMapRecord = function <T extends SchemaType, O extends SchemaOptions = {}>(
133137
valueSchema: T,
134-
options?: SchemaOptions,
135-
): LoroMapSchemaWithCatchall<{}, T> {
138+
options?: O,
139+
): LoroMapSchemaWithCatchall<{}, T> & { options: O } {
136140
return {
137141
type: "loro-map" as const,
138142
definition: {},
139143
catchallType: valueSchema,
140-
options: options || {},
144+
options: options || ({} as O),
141145
getContainerType: () => {
142146
return "Map";
143147
},
144148
catchall: <NewC extends SchemaType>(newCatchallSchema: NewC): LoroMapSchemaWithCatchall<{}, NewC> => {
145149
return schema.LoroMapRecord(newCatchallSchema, options);
146150
}
147-
} as LoroMapSchemaWithCatchall<{}, T>;
151+
} as LoroMapSchemaWithCatchall<{}, T> & { options: O };
148152
};
149153

150154
/**
151155
* Define a Loro list
152-
*
153-
* Optional `idSelector` enables more efficient updates by tracking items by stable IDs
154-
* rather than by position.
155156
*/
156-
schema.LoroList = function <T extends SchemaType>(
157+
schema.LoroList = function <T extends SchemaType, O extends SchemaOptions = {}>(
157158
itemSchema: T,
158-
// oxlint-disable-next-line no-explicit-any
159159
idSelector?: (item: any) => string,
160-
options?: SchemaOptions,
161-
): LoroListSchema<T> {
160+
options?: O,
161+
): LoroListSchema<T> & { options: O } {
162162
return {
163163
type: "loro-list" as const,
164164
itemSchema,
165165
idSelector,
166-
options: options || {},
167-
getContainerType() {
166+
options: options || ({} as O),
167+
getContainerType: () => {
168168
return "List";
169169
},
170-
};
170+
} as LoroListSchema<T> & { options: O };
171171
};
172172

173-
schema.LoroMovableList = function <T extends SchemaType>(
173+
schema.LoroMovableList = function <T extends SchemaType, O extends SchemaOptions = {}>(
174174
itemSchema: T,
175-
// oxlint-disable-next-line no-explicit-any
176175
idSelector: (item: any) => string,
177-
options?: SchemaOptions,
178-
): LoroMovableListSchema<T> {
176+
options?: O,
177+
): LoroMovableListSchema<T> & { options: O } {
179178
return {
180179
type: "loro-movable-list" as const,
181180
itemSchema,
182181
idSelector,
183-
options: options || {},
184-
getContainerType() {
182+
options: options || ({} as O),
183+
getContainerType: () => {
185184
return "MovableList";
186185
},
187-
};
186+
} as LoroMovableListSchema<T> & { options: O };
188187
};
189188

190189
/**
191190
* Define a Loro text field
192191
*/
193-
schema.LoroText = function (options?: SchemaOptions): LoroTextSchemaType {
192+
schema.LoroText = function <O extends SchemaOptions = {}>(options?: O): LoroTextSchemaType & { options: O } {
194193
return {
195194
type: "loro-text" as const,
196-
options: options || {},
197-
getContainerType() {
195+
options: options || ({} as O),
196+
getContainerType: () => {
198197
return "Text";
199198
},
200-
};
199+
} as LoroTextSchemaType & { options: O };
201200
};

0 commit comments

Comments
 (0)