@loro-extended/change 0.2.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.
@@ -0,0 +1,391 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: JSON Patch values can be any type */
2
+
3
+ import type { DocShape } from "./shape.js"
4
+ import type { Draft } from "./types.js"
5
+
6
+ // =============================================================================
7
+ // JSON PATCH TYPES - Discriminated Union for Type Safety
8
+ // =============================================================================
9
+
10
+ export type JsonPatchAddOperation = {
11
+ op: "add"
12
+ path: string | (string | number)[]
13
+ value: any
14
+ }
15
+
16
+ export type JsonPatchRemoveOperation = {
17
+ op: "remove"
18
+ path: string | (string | number)[]
19
+ }
20
+
21
+ export type JsonPatchReplaceOperation = {
22
+ op: "replace"
23
+ path: string | (string | number)[]
24
+ value: any
25
+ }
26
+
27
+ export type JsonPatchMoveOperation = {
28
+ op: "move"
29
+ path: string | (string | number)[]
30
+ from: string | (string | number)[]
31
+ }
32
+
33
+ export type JsonPatchCopyOperation = {
34
+ op: "copy"
35
+ path: string | (string | number)[]
36
+ from: string | (string | number)[]
37
+ }
38
+
39
+ export type JsonPatchTestOperation = {
40
+ op: "test"
41
+ path: string | (string | number)[]
42
+ value: any
43
+ }
44
+
45
+ export type JsonPatchOperation =
46
+ | JsonPatchAddOperation
47
+ | JsonPatchRemoveOperation
48
+ | JsonPatchReplaceOperation
49
+ | JsonPatchMoveOperation
50
+ | JsonPatchCopyOperation
51
+ | JsonPatchTestOperation
52
+
53
+ export type JsonPatch = JsonPatchOperation[]
54
+
55
+ // =============================================================================
56
+ // PATH NAVIGATION UTILITIES
57
+ // =============================================================================
58
+
59
+ /**
60
+ * Normalize JSON Pointer string to path array
61
+ * Handles RFC 6901 escaping: ~1 -> /, ~0 -> ~
62
+ */
63
+ export function normalizePath(
64
+ path: string | (string | number)[],
65
+ ): (string | number)[] {
66
+ if (Array.isArray(path)) {
67
+ return path
68
+ }
69
+
70
+ // Handle JSON Pointer format (RFC 6901)
71
+ if (path.startsWith("/")) {
72
+ return path
73
+ .slice(1) // Remove leading slash
74
+ .split("/")
75
+ .map(segment => {
76
+ // Handle JSON Pointer escaping
77
+ const unescaped = segment.replace(/~1/g, "/").replace(/~0/g, "~")
78
+ // Try to parse as number for array indices
79
+ const asNumber = Number(unescaped)
80
+ return Number.isInteger(asNumber) && asNumber >= 0
81
+ ? asNumber
82
+ : unescaped
83
+ })
84
+ }
85
+
86
+ // Handle simple dot notation or single segment
87
+ return path.split(".").map(segment => {
88
+ const asNumber = Number(segment)
89
+ return Number.isInteger(asNumber) && asNumber >= 0 ? asNumber : segment
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Navigate to a target path using natural DraftNode property access
95
+ * This follows the existing patterns from the test suite
96
+ */
97
+ function navigateToPath<T extends DocShape>(
98
+ draft: Draft<T>,
99
+ path: (string | number)[],
100
+ ): { parent: any; key: string | number } {
101
+ if (path.length === 0) {
102
+ throw new Error("Cannot navigate to empty path")
103
+ }
104
+
105
+ let current = draft as any
106
+
107
+ // Navigate to parent of target
108
+ for (let i = 0; i < path.length - 1; i++) {
109
+ const segment = path[i]
110
+
111
+ if (typeof segment === "string") {
112
+ // Use natural property access - this leverages existing DraftNode lazy creation
113
+ current = current[segment]
114
+ if (current === undefined) {
115
+ throw new Error(`Cannot navigate to path segment: ${segment}`)
116
+ }
117
+ } else if (typeof segment === "number") {
118
+ // List/array access using get() method (following existing patterns)
119
+ if (current.get && typeof current.get === "function") {
120
+ current = current.get(segment)
121
+ if (current === undefined) {
122
+ throw new Error(`List index ${segment} does not exist`)
123
+ }
124
+ } else {
125
+ throw new Error(`Cannot use numeric index ${segment} on non-list`)
126
+ }
127
+ } else {
128
+ throw new Error(`Invalid path segment type: ${typeof segment}`)
129
+ }
130
+ }
131
+
132
+ const targetKey = path[path.length - 1]
133
+ return { parent: current, key: targetKey }
134
+ }
135
+
136
+ /**
137
+ * Get value at path using natural DraftNode access patterns
138
+ */
139
+ function getValueAtPath<T extends DocShape>(
140
+ draft: Draft<T>,
141
+ path: (string | number)[],
142
+ ): any {
143
+ if (path.length === 0) {
144
+ return draft
145
+ }
146
+
147
+ const { parent, key } = navigateToPath(draft, path)
148
+
149
+ if (typeof key === "string") {
150
+ // Use natural property access or get() method
151
+ if (parent.get && typeof parent.get === "function") {
152
+ return parent.get(key)
153
+ }
154
+ return parent[key]
155
+ } else if (typeof key === "number") {
156
+ // List access using get() method
157
+ if (parent.get && typeof parent.get === "function") {
158
+ return parent.get(key)
159
+ }
160
+ throw new Error(`Cannot use numeric index ${key} on non-list`)
161
+ }
162
+
163
+ throw new Error(`Invalid key type: ${typeof key}`)
164
+ }
165
+
166
+ // =============================================================================
167
+ // OPERATION HANDLERS - Following existing DraftNode patterns
168
+ // =============================================================================
169
+
170
+ /**
171
+ * Handle 'add' operation using existing DraftNode methods
172
+ */
173
+ function handleAdd<T extends DocShape>(
174
+ draft: Draft<T>,
175
+ operation: JsonPatchAddOperation,
176
+ ): void {
177
+ const path = normalizePath(operation.path)
178
+ const { parent, key } = navigateToPath(draft, path)
179
+
180
+ if (typeof key === "string") {
181
+ // Map-like operations - use natural assignment or set() method
182
+ if (parent.set && typeof parent.set === "function") {
183
+ parent.set(key, operation.value)
184
+ } else {
185
+ // Natural property assignment (follows existing test patterns)
186
+ parent[key] = operation.value
187
+ }
188
+ } else if (typeof key === "number") {
189
+ // List operations - use insert() method (follows existing patterns)
190
+ if (parent.insert && typeof parent.insert === "function") {
191
+ parent.insert(key, operation.value)
192
+ } else {
193
+ throw new Error(`Cannot insert at numeric index ${key} on non-list`)
194
+ }
195
+ } else {
196
+ throw new Error(`Invalid key type: ${typeof key}`)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Handle 'remove' operation using existing DraftNode methods
202
+ */
203
+ function handleRemove<T extends DocShape>(
204
+ draft: Draft<T>,
205
+ operation: JsonPatchRemoveOperation,
206
+ ): void {
207
+ const path = normalizePath(operation.path)
208
+ const { parent, key } = navigateToPath(draft, path)
209
+
210
+ if (typeof key === "string") {
211
+ // Map-like operations - use delete() method (follows existing patterns)
212
+ if (parent.delete && typeof parent.delete === "function") {
213
+ parent.delete(key)
214
+ } else {
215
+ delete parent[key]
216
+ }
217
+ } else if (typeof key === "number") {
218
+ // List operations - use delete() method with count (follows existing patterns)
219
+ if (parent.delete && typeof parent.delete === "function") {
220
+ parent.delete(key, 1)
221
+ } else {
222
+ throw new Error(`Cannot remove at numeric index ${key} on non-list`)
223
+ }
224
+ } else {
225
+ throw new Error(`Invalid key type: ${typeof key}`)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Handle 'replace' operation using existing DraftNode methods
231
+ */
232
+ function handleReplace<T extends DocShape>(
233
+ draft: Draft<T>,
234
+ operation: JsonPatchReplaceOperation,
235
+ ): void {
236
+ const path = normalizePath(operation.path)
237
+ const { parent, key } = navigateToPath(draft, path)
238
+
239
+ if (typeof key === "string") {
240
+ // Map-like operations - use set() method or natural assignment
241
+ if (parent.set && typeof parent.set === "function") {
242
+ parent.set(key, operation.value)
243
+ } else {
244
+ parent[key] = operation.value
245
+ }
246
+ } else if (typeof key === "number") {
247
+ // List operations - delete then insert (follows existing patterns)
248
+ if (
249
+ parent.delete &&
250
+ parent.insert &&
251
+ typeof parent.delete === "function" &&
252
+ typeof parent.insert === "function"
253
+ ) {
254
+ parent.delete(key, 1)
255
+ parent.insert(key, operation.value)
256
+ } else {
257
+ throw new Error(`Cannot replace at numeric index ${key} on non-list`)
258
+ }
259
+ } else {
260
+ throw new Error(`Invalid key type: ${typeof key}`)
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Handle 'move' operation using existing DraftNode methods
266
+ */
267
+ function handleMove<T extends DocShape>(
268
+ draft: Draft<T>,
269
+ operation: JsonPatchMoveOperation,
270
+ ): void {
271
+ const fromPath = normalizePath(operation.from)
272
+ const toPath = normalizePath(operation.path)
273
+
274
+ // For list moves within the same parent, we need special handling
275
+ if (
276
+ fromPath.length === toPath.length &&
277
+ fromPath.slice(0, -1).every((segment, i) => segment === toPath[i])
278
+ ) {
279
+ // Same parent container - use list move operation if available
280
+ const fromIndex = fromPath[fromPath.length - 1]
281
+ const toIndex = toPath[toPath.length - 1]
282
+
283
+ if (typeof fromIndex === "number" && typeof toIndex === "number") {
284
+ const { parent } = navigateToPath(draft, fromPath.slice(0, -1))
285
+
286
+ // Check if the parent has a move method (like LoroMovableList)
287
+ if (parent.move && typeof parent.move === "function") {
288
+ parent.move(fromIndex, toIndex)
289
+ return
290
+ }
291
+
292
+ // Otherwise, get value, remove, then add at target index
293
+ const value = getValueAtPath(draft, fromPath)
294
+ handleRemove(draft, { op: "remove", path: operation.from })
295
+
296
+ // For JSON Patch move semantics, the target index refers to the position
297
+ // in the final array, not the intermediate array after removal.
298
+ // No index adjustment needed - use the original target index.
299
+ handleAdd(draft, { op: "add", path: operation.path, value })
300
+ return
301
+ }
302
+ }
303
+
304
+ // Different parents or non-numeric indices - standard move
305
+ const value = getValueAtPath(draft, fromPath)
306
+ handleRemove(draft, { op: "remove", path: operation.from })
307
+ handleAdd(draft, { op: "add", path: operation.path, value })
308
+ }
309
+
310
+ /**
311
+ * Handle 'copy' operation using existing DraftNode methods
312
+ */
313
+ function handleCopy<T extends DocShape>(
314
+ draft: Draft<T>,
315
+ operation: JsonPatchCopyOperation,
316
+ ): void {
317
+ const fromPath = normalizePath(operation.from)
318
+
319
+ // Get the value to copy
320
+ const value = getValueAtPath(draft, fromPath)
321
+
322
+ // Add to destination (no removal)
323
+ handleAdd(draft, { op: "add", path: operation.path, value })
324
+ }
325
+
326
+ /**
327
+ * Handle 'test' operation using existing DraftNode value access
328
+ */
329
+ function handleTest<T extends DocShape>(
330
+ draft: Draft<T>,
331
+ operation: JsonPatchTestOperation,
332
+ ): boolean {
333
+ const path = normalizePath(operation.path)
334
+ const actualValue = getValueAtPath(draft, path)
335
+
336
+ // Deep equality check for test operation
337
+ return JSON.stringify(actualValue) === JSON.stringify(operation.value)
338
+ }
339
+
340
+ // =============================================================================
341
+ // MAIN APPLICATOR - Simple orchestration following existing patterns
342
+ // =============================================================================
343
+
344
+ /**
345
+ * Main JSON Patch applicator - follows existing change() patterns
346
+ */
347
+ export class JsonPatchApplicator<T extends DocShape> {
348
+ constructor(private rootDraft: Draft<T>) {}
349
+
350
+ /**
351
+ * Apply a single JSON Patch operation
352
+ */
353
+ applyOperation(operation: JsonPatchOperation): void {
354
+ switch (operation.op) {
355
+ case "add":
356
+ handleAdd(this.rootDraft, operation)
357
+ break
358
+ case "remove":
359
+ handleRemove(this.rootDraft, operation)
360
+ break
361
+ case "replace":
362
+ handleReplace(this.rootDraft, operation)
363
+ break
364
+ case "move":
365
+ handleMove(this.rootDraft, operation)
366
+ break
367
+ case "copy":
368
+ handleCopy(this.rootDraft, operation)
369
+ break
370
+ case "test":
371
+ if (!handleTest(this.rootDraft, operation)) {
372
+ throw new Error(`JSON Patch test failed at path: ${operation.path}`)
373
+ }
374
+ break
375
+ default:
376
+ // TypeScript will catch this at compile time with proper discriminated union
377
+ throw new Error(
378
+ `Unsupported JSON Patch operation: ${(operation as any).op}`,
379
+ )
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Apply multiple JSON Patch operations in sequence
385
+ */
386
+ applyPatch(patch: JsonPatch): void {
387
+ for (const operation of patch) {
388
+ this.applyOperation(operation)
389
+ }
390
+ }
391
+ }
package/src/overlay.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { Value } from "loro-crdt"
2
+ import type { ContainerShape, DocShape, ValueShape } from "./shape.js"
3
+ import { isObjectValue } from "./utils/type-guards.js"
4
+
5
+ /**
6
+ * Overlays CRDT state with empty state defaults
7
+ */
8
+ export function overlayEmptyState<Shape extends DocShape>(
9
+ shape: Shape,
10
+ crdtValue: { [key: string]: Value },
11
+ emptyValue: { [key: string]: Value },
12
+ ): { [key: string]: Value } {
13
+ if (typeof crdtValue !== "object") {
14
+ throw new Error("crdt object is required")
15
+ }
16
+
17
+ if (typeof emptyValue !== "object") {
18
+ throw new Error("empty object is required")
19
+ }
20
+
21
+ const result = { ...emptyValue }
22
+
23
+ for (const [key, propShape] of Object.entries(shape.shapes)) {
24
+ const propCrdtValue = crdtValue[key]
25
+
26
+ const propEmptyValue = emptyValue[key as keyof typeof emptyValue]
27
+
28
+ result[key as keyof typeof result] = mergeValue(
29
+ propShape,
30
+ propCrdtValue,
31
+ propEmptyValue,
32
+ )
33
+ }
34
+
35
+ return result
36
+ }
37
+
38
+ /**
39
+ * Merges individual CRDT values with empty state defaults
40
+ */
41
+ export function mergeValue<Shape extends ContainerShape | ValueShape>(
42
+ shape: Shape,
43
+ crdtValue: Value,
44
+ emptyValue: Value,
45
+ ): Value {
46
+ if (crdtValue === undefined && emptyValue === undefined) {
47
+ throw new Error("either crdt or empty value must be defined")
48
+ }
49
+
50
+ switch (shape._type) {
51
+ case "text":
52
+ return crdtValue ?? emptyValue ?? ""
53
+ case "counter":
54
+ return crdtValue ?? emptyValue ?? 0
55
+ case "list":
56
+ case "movableList":
57
+ return crdtValue ?? emptyValue ?? []
58
+ case "map": {
59
+ if (!isObjectValue(crdtValue) && crdtValue !== undefined) {
60
+ throw new Error("map crdt must be object")
61
+ }
62
+
63
+ const crdtMapValue = crdtValue ?? {}
64
+
65
+ if (!isObjectValue(emptyValue) && emptyValue !== undefined) {
66
+ throw new Error("map empty state must be object")
67
+ }
68
+
69
+ const emptyMapValue = emptyValue ?? {}
70
+
71
+ const result = { ...emptyMapValue }
72
+ for (const [key, nestedShape] of Object.entries(shape.shapes)) {
73
+ const nestedCrdtValue = crdtMapValue[key]
74
+ const nestedEmptyValue = emptyMapValue[key]
75
+
76
+ result[key as keyof typeof result] = mergeValue(
77
+ nestedShape,
78
+ nestedCrdtValue,
79
+ nestedEmptyValue,
80
+ )
81
+ }
82
+
83
+ return result
84
+ }
85
+ case "tree":
86
+ return crdtValue ?? emptyValue ?? []
87
+ default:
88
+ return crdtValue ?? emptyValue
89
+ }
90
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { Shape, TypedDoc } from "./index.js"
3
+
4
+ describe("Record Types", () => {
5
+ describe("Shape.record (Container)", () => {
6
+ it("should handle record of counters", () => {
7
+ const schema = Shape.doc({
8
+ scores: Shape.record(Shape.counter()),
9
+ })
10
+
11
+ const doc = new TypedDoc(schema, { scores: {} })
12
+
13
+ doc.change(draft => {
14
+ draft.scores.getOrCreateNode("alice").increment(10)
15
+ draft.scores.getOrCreateNode("bob").increment(5)
16
+ })
17
+
18
+ expect(doc.value.scores).toEqual({
19
+ alice: 10,
20
+ bob: 5,
21
+ })
22
+
23
+ doc.change(draft => {
24
+ draft.scores.getOrCreateNode("alice").increment(5)
25
+ draft.scores.delete("bob")
26
+ })
27
+
28
+ expect(doc.value.scores).toEqual({
29
+ alice: 15,
30
+ })
31
+ })
32
+
33
+ it("should handle record of text", () => {
34
+ const schema = Shape.doc({
35
+ notes: Shape.record(Shape.text()),
36
+ })
37
+
38
+ const doc = new TypedDoc(schema, { notes: {} })
39
+
40
+ doc.change(draft => {
41
+ draft.notes.getOrCreateNode("todo").insert(0, "Buy milk")
42
+ draft.notes.getOrCreateNode("reminders").insert(0, "Call mom")
43
+ })
44
+
45
+ expect(doc.value.notes).toEqual({
46
+ todo: "Buy milk",
47
+ reminders: "Call mom",
48
+ })
49
+ })
50
+
51
+ it("should handle record of lists", () => {
52
+ const schema = Shape.doc({
53
+ groups: Shape.record(Shape.list(Shape.plain.string())),
54
+ })
55
+
56
+ const doc = new TypedDoc(schema, { groups: {} })
57
+
58
+ doc.change(draft => {
59
+ const groupA = draft.groups.getOrCreateNode("groupA")
60
+ groupA.push("alice")
61
+ groupA.push("bob")
62
+
63
+ const groupB = draft.groups.getOrCreateNode("groupB")
64
+ groupB.push("charlie")
65
+ })
66
+
67
+ expect(doc.value.groups).toEqual({
68
+ groupA: ["alice", "bob"],
69
+ groupB: ["charlie"],
70
+ })
71
+ })
72
+ })
73
+
74
+ describe("Shape.plain.record (Value)", () => {
75
+ it("should handle record of plain strings", () => {
76
+ const schema = Shape.doc({
77
+ wrapper: Shape.map({
78
+ config: Shape.plain.record(Shape.plain.string()),
79
+ }),
80
+ })
81
+
82
+ const doc = new TypedDoc(schema, { wrapper: { config: {} } })
83
+
84
+ doc.change(draft => {
85
+ draft.wrapper.config.theme = "dark"
86
+ draft.wrapper.config.lang = "en"
87
+ })
88
+
89
+ expect(doc.value.wrapper.config).toEqual({
90
+ theme: "dark",
91
+ lang: "en",
92
+ })
93
+
94
+ doc.change(draft => {
95
+ delete draft.wrapper.config.theme
96
+ draft.wrapper.config.lang = "fr"
97
+ })
98
+
99
+ expect(doc.value.wrapper.config).toEqual({
100
+ lang: "fr",
101
+ })
102
+ })
103
+
104
+ it("should handle record of plain numbers", () => {
105
+ const schema = Shape.doc({
106
+ wrapper: Shape.map({
107
+ stats: Shape.plain.record(Shape.plain.number()),
108
+ }),
109
+ })
110
+
111
+ const doc = new TypedDoc(schema, { wrapper: { stats: { visits: 0 } } })
112
+
113
+ doc.change(draft => {
114
+ draft.wrapper.stats.visits = 100
115
+ draft.wrapper.stats.clicks = 50
116
+ })
117
+
118
+ expect(doc.value.wrapper.stats).toEqual({
119
+ visits: 100,
120
+ clicks: 50,
121
+ })
122
+ })
123
+
124
+ it("should handle nested records", () => {
125
+ const schema = Shape.doc({
126
+ wrapper: Shape.map({
127
+ settings: Shape.plain.record(
128
+ Shape.plain.record(Shape.plain.boolean()),
129
+ ),
130
+ }),
131
+ })
132
+
133
+ const doc = new TypedDoc(schema, { wrapper: { settings: {} } })
134
+
135
+ doc.change(draft => {
136
+ draft.wrapper.settings.ui = {
137
+ darkMode: true,
138
+ sidebar: false,
139
+ }
140
+ draft.wrapper.settings.notifications = {
141
+ email: true,
142
+ push: true,
143
+ }
144
+ })
145
+
146
+ expect(doc.value.wrapper.settings).toEqual({
147
+ ui: {
148
+ darkMode: true,
149
+ sidebar: false,
150
+ },
151
+ notifications: {
152
+ email: true,
153
+ push: true,
154
+ },
155
+ })
156
+ })
157
+ })
158
+
159
+ describe("Mixed Usage", () => {
160
+ it("should handle record of maps", () => {
161
+ const schema = Shape.doc({
162
+ users: Shape.record(
163
+ Shape.map({
164
+ name: Shape.plain.string(),
165
+ age: Shape.plain.number(),
166
+ }),
167
+ ),
168
+ })
169
+
170
+ const doc = new TypedDoc(schema, { users: {} })
171
+
172
+ doc.change(draft => {
173
+ const alice = draft.users.getOrCreateNode("u1")
174
+ alice.name = "Alice"
175
+ alice.age = 30
176
+
177
+ const bob = draft.users.getOrCreateNode("u2")
178
+ bob.name = "Bob"
179
+ bob.age = 25
180
+ })
181
+
182
+ expect(doc.value.users).toEqual({
183
+ u1: { name: "Alice", age: 30 },
184
+ u2: { name: "Bob", age: 25 },
185
+ })
186
+ })
187
+ })
188
+ })