@loro-extended/change 1.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -56
- package/dist/index.d.ts +192 -94
- package/dist/index.js +271 -77
- package/dist/index.js.map +1 -1
- package/package.json +3 -4
- package/src/any-shape.test.ts +164 -0
- package/src/change.test.ts +4 -2
- package/src/derive-placeholder.ts +8 -0
- package/src/index.ts +13 -2
- package/src/overlay.ts +10 -0
- package/src/path-builder.ts +131 -0
- package/src/path-compiler.ts +64 -0
- package/src/path-evaluator.ts +76 -0
- package/src/path-selector.test.ts +322 -0
- package/src/path-selector.ts +131 -0
- package/src/readonly.test.ts +5 -4
- package/src/shape.ts +120 -5
- package/src/typed-refs/base.ts +6 -0
- package/src/typed-refs/counter.test.ts +2 -1
- package/src/typed-refs/doc.ts +13 -2
- package/src/typed-refs/json-compatibility.test.ts +27 -0
- package/src/typed-refs/list-base.ts +1 -1
- package/src/typed-refs/list.test.ts +1 -1
- package/src/typed-refs/list.ts +5 -2
- package/src/typed-refs/movable-list.test.ts +1 -1
- package/src/typed-refs/movable-list.ts +2 -2
- package/src/typed-refs/record.ts +11 -2
- package/src/typed-refs/struct.ts +9 -0
- package/src/typed-refs/tree.ts +6 -0
- package/src/typed-refs/utils.ts +13 -0
- package/src/validation.ts +9 -0
- package/src/presence-interface.ts +0 -52
- package/src/typed-presence.ts +0 -96
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { createPathBuilder } from "./path-builder.js"
|
|
3
|
+
import { compileToJsonPath, hasWildcard } from "./path-compiler.js"
|
|
4
|
+
import { evaluatePathOnValue } from "./path-evaluator.js"
|
|
5
|
+
import { Shape } from "./shape.js"
|
|
6
|
+
|
|
7
|
+
describe("Path Selector DSL", () => {
|
|
8
|
+
const docShape = Shape.doc({
|
|
9
|
+
books: Shape.list(
|
|
10
|
+
Shape.struct({
|
|
11
|
+
title: Shape.text(),
|
|
12
|
+
price: Shape.plain.number(),
|
|
13
|
+
author: Shape.struct({
|
|
14
|
+
name: Shape.plain.string(),
|
|
15
|
+
}),
|
|
16
|
+
}),
|
|
17
|
+
),
|
|
18
|
+
config: Shape.struct({
|
|
19
|
+
theme: Shape.plain.string(),
|
|
20
|
+
}),
|
|
21
|
+
users: Shape.record(
|
|
22
|
+
Shape.struct({
|
|
23
|
+
name: Shape.plain.string(),
|
|
24
|
+
score: Shape.counter(),
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("createPathBuilder", () => {
|
|
30
|
+
it("should create a path builder for a doc shape", () => {
|
|
31
|
+
const builder = createPathBuilder(docShape)
|
|
32
|
+
expect(builder).toBeDefined()
|
|
33
|
+
expect(builder.books).toBeDefined()
|
|
34
|
+
expect(builder.config).toBeDefined()
|
|
35
|
+
expect(builder.users).toBeDefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("should create path segments for simple property access", () => {
|
|
39
|
+
const builder = createPathBuilder(docShape)
|
|
40
|
+
const selector = builder.config.theme
|
|
41
|
+
expect(selector.__segments).toEqual([
|
|
42
|
+
{ type: "property", key: "config" },
|
|
43
|
+
{ type: "property", key: "theme" },
|
|
44
|
+
])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("should create path segments for $each on lists", () => {
|
|
48
|
+
const builder = createPathBuilder(docShape)
|
|
49
|
+
const selector = builder.books.$each.title
|
|
50
|
+
expect(selector.__segments).toEqual([
|
|
51
|
+
{ type: "property", key: "books" },
|
|
52
|
+
{ type: "each" },
|
|
53
|
+
{ type: "property", key: "title" },
|
|
54
|
+
])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("should create path segments for $at on lists", () => {
|
|
58
|
+
const builder = createPathBuilder(docShape)
|
|
59
|
+
const selector = builder.books.$at(0).title
|
|
60
|
+
expect(selector.__segments).toEqual([
|
|
61
|
+
{ type: "property", key: "books" },
|
|
62
|
+
{ type: "index", index: 0 },
|
|
63
|
+
{ type: "property", key: "title" },
|
|
64
|
+
])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("should create path segments for $first and $last", () => {
|
|
68
|
+
const builder = createPathBuilder(docShape)
|
|
69
|
+
|
|
70
|
+
const firstSelector = builder.books.$first.title
|
|
71
|
+
expect(firstSelector.__segments).toEqual([
|
|
72
|
+
{ type: "property", key: "books" },
|
|
73
|
+
{ type: "index", index: 0 },
|
|
74
|
+
{ type: "property", key: "title" },
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
const lastSelector = builder.books.$last.title
|
|
78
|
+
expect(lastSelector.__segments).toEqual([
|
|
79
|
+
{ type: "property", key: "books" },
|
|
80
|
+
{ type: "index", index: -1 },
|
|
81
|
+
{ type: "property", key: "title" },
|
|
82
|
+
])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("should create path segments for $each on records", () => {
|
|
86
|
+
const builder = createPathBuilder(docShape)
|
|
87
|
+
const selector = builder.users.$each.name
|
|
88
|
+
expect(selector.__segments).toEqual([
|
|
89
|
+
{ type: "property", key: "users" },
|
|
90
|
+
{ type: "each" },
|
|
91
|
+
{ type: "property", key: "name" },
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("should create path segments for $key on records", () => {
|
|
96
|
+
const builder = createPathBuilder(docShape)
|
|
97
|
+
const selector = builder.users.$key("alice").name
|
|
98
|
+
expect(selector.__segments).toEqual([
|
|
99
|
+
{ type: "property", key: "users" },
|
|
100
|
+
{ type: "key", key: "alice" },
|
|
101
|
+
{ type: "property", key: "name" },
|
|
102
|
+
])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("should support nested struct access", () => {
|
|
106
|
+
const builder = createPathBuilder(docShape)
|
|
107
|
+
const selector = builder.books.$each.author.name
|
|
108
|
+
expect(selector.__segments).toEqual([
|
|
109
|
+
{ type: "property", key: "books" },
|
|
110
|
+
{ type: "each" },
|
|
111
|
+
{ type: "property", key: "author" },
|
|
112
|
+
{ type: "property", key: "name" },
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("compileToJsonPath", () => {
|
|
118
|
+
it("should compile simple property path", () => {
|
|
119
|
+
const segments = [
|
|
120
|
+
{ type: "property" as const, key: "config" },
|
|
121
|
+
{ type: "property" as const, key: "theme" },
|
|
122
|
+
]
|
|
123
|
+
expect(compileToJsonPath(segments)).toBe("$.config.theme")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("should compile path with wildcard", () => {
|
|
127
|
+
const segments = [
|
|
128
|
+
{ type: "property" as const, key: "books" },
|
|
129
|
+
{ type: "each" as const },
|
|
130
|
+
{ type: "property" as const, key: "title" },
|
|
131
|
+
]
|
|
132
|
+
expect(compileToJsonPath(segments)).toBe("$.books[*].title")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("should compile path with index", () => {
|
|
136
|
+
const segments = [
|
|
137
|
+
{ type: "property" as const, key: "books" },
|
|
138
|
+
{ type: "index" as const, index: 0 },
|
|
139
|
+
{ type: "property" as const, key: "title" },
|
|
140
|
+
]
|
|
141
|
+
expect(compileToJsonPath(segments)).toBe("$.books[0].title")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("should compile path with negative index", () => {
|
|
145
|
+
const segments = [
|
|
146
|
+
{ type: "property" as const, key: "books" },
|
|
147
|
+
{ type: "index" as const, index: -1 },
|
|
148
|
+
{ type: "property" as const, key: "title" },
|
|
149
|
+
]
|
|
150
|
+
expect(compileToJsonPath(segments)).toBe("$.books[-1].title")
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("should compile path with key", () => {
|
|
154
|
+
const segments = [
|
|
155
|
+
{ type: "property" as const, key: "users" },
|
|
156
|
+
{ type: "key" as const, key: "alice" },
|
|
157
|
+
{ type: "property" as const, key: "name" },
|
|
158
|
+
]
|
|
159
|
+
expect(compileToJsonPath(segments)).toBe('$.users["alice"].name')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("should use bracket notation for special characters", () => {
|
|
163
|
+
const segments = [{ type: "property" as const, key: "my-key" }]
|
|
164
|
+
expect(compileToJsonPath(segments)).toBe('$["my-key"]')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe("hasWildcard", () => {
|
|
169
|
+
it("should return true for paths with $each", () => {
|
|
170
|
+
const segments = [
|
|
171
|
+
{ type: "property" as const, key: "books" },
|
|
172
|
+
{ type: "each" as const },
|
|
173
|
+
{ type: "property" as const, key: "title" },
|
|
174
|
+
]
|
|
175
|
+
expect(hasWildcard(segments)).toBe(true)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("should return false for paths without $each", () => {
|
|
179
|
+
const segments = [
|
|
180
|
+
{ type: "property" as const, key: "config" },
|
|
181
|
+
{ type: "property" as const, key: "theme" },
|
|
182
|
+
]
|
|
183
|
+
expect(hasWildcard(segments)).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("should return false for paths with index", () => {
|
|
187
|
+
const segments = [
|
|
188
|
+
{ type: "property" as const, key: "books" },
|
|
189
|
+
{ type: "index" as const, index: 0 },
|
|
190
|
+
{ type: "property" as const, key: "title" },
|
|
191
|
+
]
|
|
192
|
+
expect(hasWildcard(segments)).toBe(false)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe("evaluatePathOnValue", () => {
|
|
197
|
+
const testData = {
|
|
198
|
+
books: [
|
|
199
|
+
{ title: "Book 1", price: 10, author: { name: "Author 1" } },
|
|
200
|
+
{ title: "Book 2", price: 20, author: { name: "Author 2" } },
|
|
201
|
+
{ title: "Book 3", price: 30, author: { name: "Author 3" } },
|
|
202
|
+
],
|
|
203
|
+
config: { theme: "dark" },
|
|
204
|
+
users: {
|
|
205
|
+
alice: { name: "Alice", score: 100 },
|
|
206
|
+
bob: { name: "Bob", score: 200 },
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
it("should evaluate simple property path", () => {
|
|
211
|
+
const segments = [
|
|
212
|
+
{ type: "property" as const, key: "config" },
|
|
213
|
+
{ type: "property" as const, key: "theme" },
|
|
214
|
+
]
|
|
215
|
+
expect(evaluatePathOnValue(testData, segments)).toBe("dark")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("should evaluate path with wildcard on array", () => {
|
|
219
|
+
const segments = [
|
|
220
|
+
{ type: "property" as const, key: "books" },
|
|
221
|
+
{ type: "each" as const },
|
|
222
|
+
{ type: "property" as const, key: "title" },
|
|
223
|
+
]
|
|
224
|
+
expect(evaluatePathOnValue(testData, segments)).toEqual([
|
|
225
|
+
"Book 1",
|
|
226
|
+
"Book 2",
|
|
227
|
+
"Book 3",
|
|
228
|
+
])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("should evaluate path with positive index", () => {
|
|
232
|
+
const segments = [
|
|
233
|
+
{ type: "property" as const, key: "books" },
|
|
234
|
+
{ type: "index" as const, index: 1 },
|
|
235
|
+
{ type: "property" as const, key: "title" },
|
|
236
|
+
]
|
|
237
|
+
expect(evaluatePathOnValue(testData, segments)).toBe("Book 2")
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("should evaluate path with negative index", () => {
|
|
241
|
+
const segments = [
|
|
242
|
+
{ type: "property" as const, key: "books" },
|
|
243
|
+
{ type: "index" as const, index: -1 },
|
|
244
|
+
{ type: "property" as const, key: "title" },
|
|
245
|
+
]
|
|
246
|
+
expect(evaluatePathOnValue(testData, segments)).toBe("Book 3")
|
|
247
|
+
|
|
248
|
+
const segments2 = [
|
|
249
|
+
{ type: "property" as const, key: "books" },
|
|
250
|
+
{ type: "index" as const, index: -2 },
|
|
251
|
+
{ type: "property" as const, key: "title" },
|
|
252
|
+
]
|
|
253
|
+
expect(evaluatePathOnValue(testData, segments2)).toBe("Book 2")
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("should evaluate path with key on record", () => {
|
|
257
|
+
const segments = [
|
|
258
|
+
{ type: "property" as const, key: "users" },
|
|
259
|
+
{ type: "key" as const, key: "alice" },
|
|
260
|
+
{ type: "property" as const, key: "name" },
|
|
261
|
+
]
|
|
262
|
+
expect(evaluatePathOnValue(testData, segments)).toBe("Alice")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("should evaluate path with wildcard on record", () => {
|
|
266
|
+
const segments = [
|
|
267
|
+
{ type: "property" as const, key: "users" },
|
|
268
|
+
{ type: "each" as const },
|
|
269
|
+
{ type: "property" as const, key: "name" },
|
|
270
|
+
]
|
|
271
|
+
const result = evaluatePathOnValue(testData, segments) as string[]
|
|
272
|
+
expect(result).toContain("Alice")
|
|
273
|
+
expect(result).toContain("Bob")
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it("should evaluate nested path through wildcard", () => {
|
|
277
|
+
const segments = [
|
|
278
|
+
{ type: "property" as const, key: "books" },
|
|
279
|
+
{ type: "each" as const },
|
|
280
|
+
{ type: "property" as const, key: "author" },
|
|
281
|
+
{ type: "property" as const, key: "name" },
|
|
282
|
+
]
|
|
283
|
+
expect(evaluatePathOnValue(testData, segments)).toEqual([
|
|
284
|
+
"Author 1",
|
|
285
|
+
"Author 2",
|
|
286
|
+
"Author 3",
|
|
287
|
+
])
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it("should return undefined for missing property", () => {
|
|
291
|
+
const segments = [{ type: "property" as const, key: "nonexistent" }]
|
|
292
|
+
expect(evaluatePathOnValue(testData, segments)).toBeUndefined()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it("should return undefined for out-of-bounds index", () => {
|
|
296
|
+
const segments = [
|
|
297
|
+
{ type: "property" as const, key: "books" },
|
|
298
|
+
{ type: "index" as const, index: 10 },
|
|
299
|
+
{ type: "property" as const, key: "title" },
|
|
300
|
+
]
|
|
301
|
+
expect(evaluatePathOnValue(testData, segments)).toBeUndefined()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it("should return undefined for out-of-bounds negative index", () => {
|
|
305
|
+
const segments = [
|
|
306
|
+
{ type: "property" as const, key: "books" },
|
|
307
|
+
{ type: "index" as const, index: -10 },
|
|
308
|
+
{ type: "property" as const, key: "title" },
|
|
309
|
+
]
|
|
310
|
+
expect(evaluatePathOnValue(testData, segments)).toBeUndefined()
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it("should return empty array for wildcard on empty array", () => {
|
|
314
|
+
const segments = [
|
|
315
|
+
{ type: "property" as const, key: "books" },
|
|
316
|
+
{ type: "each" as const },
|
|
317
|
+
{ type: "property" as const, key: "title" },
|
|
318
|
+
]
|
|
319
|
+
expect(evaluatePathOnValue({ books: [] }, segments)).toEqual([])
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Type-Safe Path Selector DSL
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// This module provides type definitions for a type-safe path selector DSL
|
|
6
|
+
// that compiles to JSONPath strings for WASM-side filtering.
|
|
7
|
+
//
|
|
8
|
+
// See plans/typed-path-selector-dsl.md for full design documentation.
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ContainerOrValueShape,
|
|
12
|
+
CounterContainerShape,
|
|
13
|
+
DocShape,
|
|
14
|
+
ListContainerShape,
|
|
15
|
+
MovableListContainerShape,
|
|
16
|
+
RecordContainerShape,
|
|
17
|
+
StructContainerShape,
|
|
18
|
+
TextContainerShape,
|
|
19
|
+
ValueShape,
|
|
20
|
+
} from "./shape.js"
|
|
21
|
+
import type { Infer } from "./types.js"
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Path Segment Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export type PathSegment =
|
|
28
|
+
| { type: "property"; key: string }
|
|
29
|
+
| { type: "each" } // Wildcard for arrays/records
|
|
30
|
+
| { type: "index"; index: number } // Specific array index (supports negative)
|
|
31
|
+
| { type: "key"; key: string } // Specific record key
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Path Selector (carries type and segments)
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
export interface PathSelector<T> {
|
|
38
|
+
readonly __resultType: T // Phantom type for inference
|
|
39
|
+
readonly __segments: PathSegment[] // Runtime path data
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Path Node Types (for each container type)
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
// List path node - InArray tracks if we're inside a wildcard
|
|
47
|
+
interface ListPathNode<
|
|
48
|
+
Item extends ContainerOrValueShape,
|
|
49
|
+
InArray extends boolean,
|
|
50
|
+
> extends PathSelector<WrapType<Infer<Item>[], InArray>> {
|
|
51
|
+
/** Select all items (wildcard) - sets InArray to true for children */
|
|
52
|
+
readonly $each: PathNode<Item, true>
|
|
53
|
+
/** Select item at specific index (supports negative indices: -1 = last, -2 = second-to-last, etc.) */
|
|
54
|
+
$at(index: number): PathNode<Item, InArray>
|
|
55
|
+
/** Select first item (alias for $at(0)) */
|
|
56
|
+
readonly $first: PathNode<Item, InArray>
|
|
57
|
+
/** Select last item (alias for $at(-1)) */
|
|
58
|
+
readonly $last: PathNode<Item, InArray>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Struct path node (fixed keys) - propagates InArray to children
|
|
62
|
+
type StructPathNode<
|
|
63
|
+
Shapes extends Record<string, ContainerOrValueShape>,
|
|
64
|
+
InArray extends boolean,
|
|
65
|
+
> = PathSelector<
|
|
66
|
+
WrapType<{ [K in keyof Shapes]: Infer<Shapes[K]> }, InArray>
|
|
67
|
+
> & {
|
|
68
|
+
readonly [K in keyof Shapes]: PathNode<Shapes[K], InArray>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Record path node (dynamic keys) - propagates InArray to children
|
|
72
|
+
interface RecordPathNode<
|
|
73
|
+
Item extends ContainerOrValueShape,
|
|
74
|
+
InArray extends boolean,
|
|
75
|
+
> extends PathSelector<WrapType<Record<string, Infer<Item>>, InArray>> {
|
|
76
|
+
/** Select all values (wildcard) - sets InArray to true for children */
|
|
77
|
+
readonly $each: PathNode<Item, true>
|
|
78
|
+
/** Select value at specific key */
|
|
79
|
+
$key(key: string): PathNode<Item, InArray>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Text path node (terminal)
|
|
83
|
+
type TextPathNode<InArray extends boolean> = PathSelector<
|
|
84
|
+
WrapType<string, InArray>
|
|
85
|
+
>
|
|
86
|
+
|
|
87
|
+
// Counter path node (terminal)
|
|
88
|
+
type CounterPathNode<InArray extends boolean> = PathSelector<
|
|
89
|
+
WrapType<number, InArray>
|
|
90
|
+
>
|
|
91
|
+
|
|
92
|
+
// Terminal node for primitive values
|
|
93
|
+
type TerminalPathNode<T, InArray extends boolean> = PathSelector<
|
|
94
|
+
WrapType<T, InArray>
|
|
95
|
+
>
|
|
96
|
+
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// PathNode Type Mapping
|
|
99
|
+
// ============================================================================
|
|
100
|
+
|
|
101
|
+
// Helper: wrap type in array if InArray is true
|
|
102
|
+
type WrapType<T, InArray extends boolean> = InArray extends true ? T[] : T
|
|
103
|
+
|
|
104
|
+
// InArray tracks whether we've passed through a wildcard ($each)
|
|
105
|
+
// This affects the result type: T vs T[]
|
|
106
|
+
export type PathNode<
|
|
107
|
+
S extends ContainerOrValueShape,
|
|
108
|
+
InArray extends boolean,
|
|
109
|
+
> = S extends ListContainerShape<infer Item>
|
|
110
|
+
? ListPathNode<Item, InArray>
|
|
111
|
+
: S extends MovableListContainerShape<infer Item>
|
|
112
|
+
? ListPathNode<Item, InArray>
|
|
113
|
+
: S extends StructContainerShape<infer Shapes>
|
|
114
|
+
? StructPathNode<Shapes, InArray>
|
|
115
|
+
: S extends RecordContainerShape<infer Item>
|
|
116
|
+
? RecordPathNode<Item, InArray>
|
|
117
|
+
: S extends TextContainerShape
|
|
118
|
+
? TextPathNode<InArray>
|
|
119
|
+
: S extends CounterContainerShape
|
|
120
|
+
? CounterPathNode<InArray>
|
|
121
|
+
: S extends ValueShape
|
|
122
|
+
? TerminalPathNode<Infer<S>, InArray>
|
|
123
|
+
: never
|
|
124
|
+
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Path Builder (entry point)
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
export type PathBuilder<D extends DocShape> = {
|
|
130
|
+
readonly [K in keyof D["shapes"]]: PathNode<D["shapes"][K], false>
|
|
131
|
+
}
|
package/src/readonly.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change } from "./functional-helpers.js"
|
|
2
3
|
import { Shape } from "./shape.js"
|
|
3
4
|
import { createTypedDoc } from "./typed-doc.js"
|
|
4
5
|
|
|
@@ -14,7 +15,7 @@ describe("TypedDoc Mutable Mode", () => {
|
|
|
14
15
|
it("should read values correctly", () => {
|
|
15
16
|
const doc = createTypedDoc(schema)
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
change(doc, d => {
|
|
18
19
|
d.meta.count = 1
|
|
19
20
|
d.meta.title = "updated"
|
|
20
21
|
d.list.push("item1")
|
|
@@ -33,7 +34,7 @@ describe("TypedDoc Mutable Mode", () => {
|
|
|
33
34
|
|
|
34
35
|
expect(liveMeta.count).toBe(0)
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
change(doc, d => {
|
|
37
38
|
d.meta.count = 5
|
|
38
39
|
})
|
|
39
40
|
|
|
@@ -58,7 +59,7 @@ describe("TypedDoc Mutable Mode", () => {
|
|
|
58
59
|
it("should support change() for grouped mutations", () => {
|
|
59
60
|
const doc = createTypedDoc(schema)
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
change(doc, d => {
|
|
62
63
|
d.meta.count = 1
|
|
63
64
|
d.meta.title = "batched"
|
|
64
65
|
d.list.push("a")
|
|
@@ -74,7 +75,7 @@ describe("TypedDoc Mutable Mode", () => {
|
|
|
74
75
|
it("should support toJSON for full serialization", () => {
|
|
75
76
|
const doc = createTypedDoc(schema)
|
|
76
77
|
|
|
77
|
-
|
|
78
|
+
change(doc, d => {
|
|
78
79
|
d.meta.count = 1
|
|
79
80
|
d.meta.title = "json"
|
|
80
81
|
d.list.push("a")
|
package/src/shape.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
LoroMovableList,
|
|
8
8
|
LoroText,
|
|
9
9
|
LoroTree,
|
|
10
|
+
Value,
|
|
10
11
|
} from "loro-crdt"
|
|
11
12
|
|
|
12
13
|
import type { CounterRef } from "./typed-refs/counter.js"
|
|
@@ -128,7 +129,24 @@ export interface RecordContainerShape<
|
|
|
128
129
|
readonly shape: NestedShape
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Container escape hatch - represents "any LoroContainer".
|
|
134
|
+
* Use this when integrating with external libraries that manage their own document structure.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```typescript
|
|
138
|
+
* // loro-prosemirror manages its own structure
|
|
139
|
+
* const ProseMirrorDocShape = Shape.doc({
|
|
140
|
+
* doc: Shape.any(), // opt out of typing for this container
|
|
141
|
+
* })
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export interface AnyContainerShape extends Shape<unknown, unknown, undefined> {
|
|
145
|
+
readonly _type: "any"
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
export type ContainerShape =
|
|
149
|
+
| AnyContainerShape
|
|
132
150
|
| CounterContainerShape
|
|
133
151
|
| ListContainerShape
|
|
134
152
|
| MovableListContainerShape
|
|
@@ -250,8 +268,25 @@ export interface DiscriminatedUnionValueShape<
|
|
|
250
268
|
readonly variants: T
|
|
251
269
|
}
|
|
252
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Value escape hatch - represents "any Loro Value".
|
|
273
|
+
* Use this when you need to accept any valid Loro value type.
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* ```typescript
|
|
277
|
+
* const FlexiblePresenceShape = Shape.plain.struct({
|
|
278
|
+
* cursor: Shape.plain.any(), // accept any value type
|
|
279
|
+
* })
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
export interface AnyValueShape extends Shape<Value, Value, undefined> {
|
|
283
|
+
readonly _type: "value"
|
|
284
|
+
readonly valueType: "any"
|
|
285
|
+
}
|
|
286
|
+
|
|
253
287
|
// Union of all ValueShapes - these can only contain other ValueShapes, not ContainerShapes
|
|
254
288
|
export type ValueShape =
|
|
289
|
+
| AnyValueShape
|
|
255
290
|
| StringValueShape
|
|
256
291
|
| NumberValueShape
|
|
257
292
|
| BooleanValueShape
|
|
@@ -317,6 +352,28 @@ export const Shape = {
|
|
|
317
352
|
_placeholder: {} as any,
|
|
318
353
|
}),
|
|
319
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Creates an "any" container shape - an escape hatch for untyped containers.
|
|
357
|
+
* Use this when integrating with external libraries that manage their own document structure.
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* ```typescript
|
|
361
|
+
* // loro-prosemirror manages its own structure
|
|
362
|
+
* const ProseMirrorDocShape = Shape.doc({
|
|
363
|
+
* doc: Shape.any(), // opt out of typing for this container
|
|
364
|
+
* })
|
|
365
|
+
*
|
|
366
|
+
* const handle = repo.get(docId, ProseMirrorDocShape, CursorPresenceShape)
|
|
367
|
+
* // handle.doc.doc is typed as `unknown` - you're on your own
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
any: (): AnyContainerShape => ({
|
|
371
|
+
_type: "any" as const,
|
|
372
|
+
_plain: undefined as unknown,
|
|
373
|
+
_mutable: undefined as unknown,
|
|
374
|
+
_placeholder: undefined,
|
|
375
|
+
}),
|
|
376
|
+
|
|
320
377
|
// CRDTs are represented by Loro Containers--they converge on state using Loro's
|
|
321
378
|
// various CRDT algorithms
|
|
322
379
|
counter: (): WithPlaceholder<CounterContainerShape> => {
|
|
@@ -509,12 +566,70 @@ export const Shape = {
|
|
|
509
566
|
_placeholder: undefined,
|
|
510
567
|
}),
|
|
511
568
|
|
|
512
|
-
uint8Array: (): Uint8ArrayValueShape
|
|
569
|
+
uint8Array: (): Uint8ArrayValueShape &
|
|
570
|
+
WithNullable<Uint8ArrayValueShape> => {
|
|
571
|
+
const base: Uint8ArrayValueShape = {
|
|
572
|
+
_type: "value" as const,
|
|
573
|
+
valueType: "uint8array" as const,
|
|
574
|
+
_plain: new Uint8Array(),
|
|
575
|
+
_mutable: new Uint8Array(),
|
|
576
|
+
_placeholder: new Uint8Array(),
|
|
577
|
+
}
|
|
578
|
+
return Object.assign(base, {
|
|
579
|
+
nullable(): WithPlaceholder<
|
|
580
|
+
UnionValueShape<[NullValueShape, Uint8ArrayValueShape]>
|
|
581
|
+
> {
|
|
582
|
+
return makeNullable(base)
|
|
583
|
+
},
|
|
584
|
+
})
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Alias for `uint8Array()` - creates a shape for binary data.
|
|
589
|
+
* Use this for better discoverability when working with binary data like cursor positions.
|
|
590
|
+
*
|
|
591
|
+
* @example
|
|
592
|
+
* ```typescript
|
|
593
|
+
* const CursorPresenceShape = Shape.plain.struct({
|
|
594
|
+
* anchor: Shape.plain.bytes().nullable(),
|
|
595
|
+
* focus: Shape.plain.bytes().nullable(),
|
|
596
|
+
* })
|
|
597
|
+
* ```
|
|
598
|
+
*/
|
|
599
|
+
bytes: (): Uint8ArrayValueShape & WithNullable<Uint8ArrayValueShape> => {
|
|
600
|
+
const base: Uint8ArrayValueShape = {
|
|
601
|
+
_type: "value" as const,
|
|
602
|
+
valueType: "uint8array" as const,
|
|
603
|
+
_plain: new Uint8Array(),
|
|
604
|
+
_mutable: new Uint8Array(),
|
|
605
|
+
_placeholder: new Uint8Array(),
|
|
606
|
+
}
|
|
607
|
+
return Object.assign(base, {
|
|
608
|
+
nullable(): WithPlaceholder<
|
|
609
|
+
UnionValueShape<[NullValueShape, Uint8ArrayValueShape]>
|
|
610
|
+
> {
|
|
611
|
+
return makeNullable(base)
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Creates an "any" value shape - an escape hatch for untyped values.
|
|
618
|
+
* Use this when you need to accept any valid Loro value type.
|
|
619
|
+
*
|
|
620
|
+
* @example
|
|
621
|
+
* ```typescript
|
|
622
|
+
* const FlexiblePresenceShape = Shape.plain.struct({
|
|
623
|
+
* metadata: Shape.plain.any(), // accept any value type
|
|
624
|
+
* })
|
|
625
|
+
* ```
|
|
626
|
+
*/
|
|
627
|
+
any: (): AnyValueShape => ({
|
|
513
628
|
_type: "value" as const,
|
|
514
|
-
valueType: "
|
|
515
|
-
_plain:
|
|
516
|
-
_mutable:
|
|
517
|
-
_placeholder:
|
|
629
|
+
valueType: "any" as const,
|
|
630
|
+
_plain: undefined as unknown as Value,
|
|
631
|
+
_mutable: undefined as unknown as Value,
|
|
632
|
+
_placeholder: undefined,
|
|
518
633
|
}),
|
|
519
634
|
|
|
520
635
|
/**
|
package/src/typed-refs/base.ts
CHANGED
|
@@ -19,6 +19,12 @@ export abstract class TypedRef<Shape extends DocShape | ContainerShape> {
|
|
|
19
19
|
|
|
20
20
|
abstract absorbPlainValues(): void
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Serializes the ref to a plain JSON-compatible value.
|
|
24
|
+
* Returns the plain type inferred from the shape.
|
|
25
|
+
*/
|
|
26
|
+
abstract toJSON(): Infer<Shape>
|
|
27
|
+
|
|
22
28
|
protected get shape(): Shape {
|
|
23
29
|
return this._params.shape
|
|
24
30
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { change } from "../functional-helpers.js"
|
|
2
3
|
import { Shape } from "../shape.js"
|
|
3
4
|
import { createTypedDoc } from "../typed-doc.js"
|
|
4
5
|
|
|
@@ -57,7 +58,7 @@ describe("Counter Ref", () => {
|
|
|
57
58
|
})
|
|
58
59
|
const doc = createTypedDoc(schema)
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
change(doc, draft => {
|
|
61
62
|
draft.counter.increment(5)
|
|
62
63
|
})
|
|
63
64
|
|