@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,220 @@
1
+ import {
2
+ type Container,
3
+ LoroCounter,
4
+ LoroList,
5
+ LoroMap,
6
+ LoroMovableList,
7
+ LoroText,
8
+ type Value,
9
+ } from "loro-crdt"
10
+ import type {
11
+ ArrayValueShape,
12
+ ContainerOrValueShape,
13
+ ListContainerShape,
14
+ MapContainerShape,
15
+ MovableListContainerShape,
16
+ ObjectValueShape,
17
+ RecordContainerShape,
18
+ RecordValueShape,
19
+ } from "./shape.js"
20
+ import {
21
+ isContainer,
22
+ isContainerShape,
23
+ isObjectValue,
24
+ isValueShape,
25
+ } from "./utils/type-guards.js"
26
+
27
+ /**
28
+ * Converts string input to LoroText container
29
+ */
30
+ function convertTextInput(value: string): LoroText {
31
+ const text = new LoroText()
32
+
33
+ // TODO(duane): condition can be removed when https://github.com/loro-dev/loro/issues/872 is addressed
34
+ if (value.length > 0) {
35
+ text.insert(0, value)
36
+ }
37
+
38
+ return text
39
+ }
40
+
41
+ /**
42
+ * Converts number input to LoroCounter container
43
+ */
44
+ function convertCounterInput(value: number): LoroCounter {
45
+ const counter = new LoroCounter()
46
+ counter.increment(value)
47
+ return counter
48
+ }
49
+
50
+ /**
51
+ * Converts array input to LoroList container
52
+ */
53
+ function convertListInput(
54
+ value: Value[],
55
+ shape: ListContainerShape | ArrayValueShape,
56
+ // parentPath: string[],
57
+ ): LoroList | Value[] {
58
+ if (!isContainerShape(shape)) {
59
+ return value
60
+ }
61
+
62
+ const list = new LoroList()
63
+
64
+ for (const item of value) {
65
+ const convertedItem = convertInputToNode(item, shape.shape)
66
+ if (isContainer(convertedItem)) {
67
+ list.pushContainer(convertedItem)
68
+ } else {
69
+ list.push(convertedItem)
70
+ }
71
+ }
72
+
73
+ return list
74
+ }
75
+
76
+ /**
77
+ * Converts array input to LoroMovableList container
78
+ */
79
+ function convertMovableListInput(
80
+ value: Value[],
81
+ shape: MovableListContainerShape | ArrayValueShape,
82
+ // parentPath: string[],
83
+ ): LoroMovableList | Value[] {
84
+ if (!isContainerShape(shape)) {
85
+ return value
86
+ }
87
+
88
+ const list = new LoroMovableList()
89
+
90
+ for (const item of value) {
91
+ const convertedItem = convertInputToNode(item, shape.shape)
92
+ if (isContainer(convertedItem)) {
93
+ list.pushContainer(convertedItem)
94
+ } else {
95
+ list.push(convertedItem)
96
+ }
97
+ }
98
+
99
+ return list
100
+ }
101
+
102
+ /**
103
+ * Converts object input to LoroMap container
104
+ */
105
+ function convertMapInput(
106
+ value: { [key: string]: Value },
107
+ shape: MapContainerShape | ObjectValueShape,
108
+ ): LoroMap | { [key: string]: Value } {
109
+ if (!isContainerShape(shape)) {
110
+ return value
111
+ }
112
+
113
+ const map = new LoroMap()
114
+ for (const [k, v] of Object.entries(value)) {
115
+ const nestedSchema = shape.shapes[k]
116
+ if (nestedSchema) {
117
+ const convertedValue = convertInputToNode(v, nestedSchema)
118
+ if (isContainer(convertedValue)) {
119
+ map.setContainer(k, convertedValue)
120
+ } else {
121
+ map.set(k, convertedValue)
122
+ }
123
+ } else {
124
+ map.set(k, value)
125
+ }
126
+ }
127
+
128
+ return map
129
+ }
130
+
131
+ /**
132
+ * Converts object input to LoroMap container (Record)
133
+ */
134
+ function convertRecordInput(
135
+ value: { [key: string]: Value },
136
+ shape: RecordContainerShape | RecordValueShape,
137
+ ): LoroMap | { [key: string]: Value } {
138
+ if (!isContainerShape(shape)) {
139
+ return value
140
+ }
141
+
142
+ const map = new LoroMap()
143
+ for (const [k, v] of Object.entries(value)) {
144
+ const convertedValue = convertInputToNode(v, shape.shape)
145
+ if (isContainer(convertedValue)) {
146
+ map.setContainer(k, convertedValue)
147
+ } else {
148
+ map.set(k, convertedValue)
149
+ }
150
+ }
151
+
152
+ return map
153
+ }
154
+
155
+ /**
156
+ * Main conversion function that transforms input values to appropriate CRDT containers
157
+ * based on schema definitions
158
+ */
159
+ export function convertInputToNode<Shape extends ContainerOrValueShape>(
160
+ value: Value,
161
+ shape: Shape,
162
+ ): Container | Value {
163
+ switch (shape._type) {
164
+ case "text": {
165
+ if (typeof value !== "string") {
166
+ throw new Error("string expected")
167
+ }
168
+
169
+ return convertTextInput(value)
170
+ }
171
+ case "counter": {
172
+ if (typeof value !== "number") {
173
+ throw new Error("number expected")
174
+ }
175
+
176
+ return convertCounterInput(value)
177
+ }
178
+ case "list": {
179
+ if (!Array.isArray(value)) {
180
+ throw new Error("array expected")
181
+ }
182
+
183
+ return convertListInput(value, shape)
184
+ }
185
+ case "movableList": {
186
+ if (!Array.isArray(value)) {
187
+ throw new Error("array expected")
188
+ }
189
+
190
+ return convertMovableListInput(value, shape)
191
+ }
192
+ case "map": {
193
+ if (!isObjectValue(value)) {
194
+ throw new Error("object expected")
195
+ }
196
+
197
+ return convertMapInput(value, shape)
198
+ }
199
+ case "record": {
200
+ if (!isObjectValue(value)) {
201
+ throw new Error("object expected")
202
+ }
203
+
204
+ return convertRecordInput(value, shape)
205
+ }
206
+ case "value": {
207
+ if (!isValueShape(shape)) {
208
+ throw new Error("value expected")
209
+ }
210
+
211
+ return value
212
+ }
213
+
214
+ case "tree":
215
+ throw new Error("tree type unimplemented")
216
+
217
+ default:
218
+ throw new Error(`unexpected type: ${(shape as Shape)._type}`)
219
+ }
220
+ }
@@ -0,0 +1,34 @@
1
+ import type { ContainerShape, DocShape, ShapeToContainer } from "../shape.js"
2
+ import type { InferPlainType } from "../types.js"
3
+
4
+ export type DraftNodeParams<Shape extends DocShape | ContainerShape> = {
5
+ shape: Shape
6
+ emptyState?: InferPlainType<Shape>
7
+ getContainer: () => ShapeToContainer<Shape>
8
+ }
9
+
10
+ // Base class for all draft nodes
11
+ export abstract class DraftNode<Shape extends DocShape | ContainerShape> {
12
+ protected _cachedContainer?: ShapeToContainer<Shape>
13
+
14
+ constructor(protected _params: DraftNodeParams<Shape>) {}
15
+
16
+ abstract absorbPlainValues(): void
17
+
18
+ protected get shape(): Shape {
19
+ return this._params.shape
20
+ }
21
+
22
+ protected get emptyState(): InferPlainType<Shape> | undefined {
23
+ return this._params.emptyState
24
+ }
25
+
26
+ protected get container(): ShapeToContainer<Shape> {
27
+ if (!this._cachedContainer) {
28
+ const container = this._params.getContainer()
29
+ this._cachedContainer = container
30
+ return container
31
+ }
32
+ return this._cachedContainer
33
+ }
34
+ }
@@ -0,0 +1,21 @@
1
+ import type { CounterContainerShape } from "../shape.js"
2
+ import { DraftNode } from "./base.js"
3
+
4
+ // Counter draft node
5
+ export class CounterDraftNode extends DraftNode<CounterContainerShape> {
6
+ absorbPlainValues() {
7
+ // no plain values contained within
8
+ }
9
+
10
+ increment(value: number): void {
11
+ this.container.increment(value)
12
+ }
13
+
14
+ decrement(value: number): void {
15
+ this.container.decrement(value)
16
+ }
17
+
18
+ get value(): number {
19
+ return this.container.value
20
+ }
21
+ }
@@ -0,0 +1,81 @@
1
+ import type { LoroDoc } from "loro-crdt"
2
+ import type { InferPlainType } from "../index.js"
3
+ import type { ContainerShape, DocShape } from "../shape.js"
4
+ import { DraftNode, type DraftNodeParams } from "./base.js"
5
+ import { createContainerDraftNode } from "./utils.js"
6
+
7
+ const containerGetter = {
8
+ counter: "getCounter",
9
+ list: "getList",
10
+ map: "getMap",
11
+ movableList: "getMovableList",
12
+ record: "getMap",
13
+ text: "getText",
14
+ tree: "getTree",
15
+ } as const
16
+
17
+ // Draft Document class -- the actual object passed to the change `mutation` function
18
+ export class DraftDoc<Shape extends DocShape> extends DraftNode<Shape> {
19
+ private doc: LoroDoc
20
+ private propertyCache = new Map<string, DraftNode<ContainerShape>>()
21
+ private requiredEmptyState!: InferPlainType<Shape>
22
+
23
+ constructor(
24
+ _params: Omit<DraftNodeParams<Shape>, "getContainer"> & { doc: LoroDoc },
25
+ ) {
26
+ super({
27
+ ..._params,
28
+ getContainer: () => {
29
+ throw new Error("can't get container on DraftDoc")
30
+ },
31
+ })
32
+ if (!_params.emptyState) throw new Error("emptyState required")
33
+ this.doc = _params.doc
34
+ this.requiredEmptyState = _params.emptyState
35
+ this.createLazyProperties()
36
+ }
37
+
38
+ getDraftNodeParams<S extends ContainerShape>(
39
+ key: string,
40
+ shape: S,
41
+ ): DraftNodeParams<ContainerShape> {
42
+ const getter = this.doc[containerGetter[shape._type]].bind(this.doc)
43
+
44
+ return {
45
+ shape,
46
+ emptyState: this.requiredEmptyState[key],
47
+ getContainer: () => getter(key),
48
+ }
49
+ }
50
+
51
+ getOrCreateDraftNode(
52
+ key: string,
53
+ shape: ContainerShape,
54
+ ): DraftNode<ContainerShape> {
55
+ let node = this.propertyCache.get(key)
56
+
57
+ if (!node) {
58
+ node = createContainerDraftNode(this.getDraftNodeParams(key, shape))
59
+ this.propertyCache.set(key, node)
60
+ }
61
+
62
+ return node
63
+ }
64
+
65
+ private createLazyProperties(): void {
66
+ for (const key in this.shape.shapes) {
67
+ const shape = this.shape.shapes[key]
68
+ Object.defineProperty(this, key, {
69
+ get: () => this.getOrCreateDraftNode(key, shape),
70
+ })
71
+ }
72
+ }
73
+
74
+ absorbPlainValues(): void {
75
+ // By iterating over the propertyCache, we achieve a small optimization
76
+ // by only absorbing values that have been 'touched' in some way
77
+ for (const [, node] of this.propertyCache.entries()) {
78
+ node.absorbPlainValues()
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,326 @@
1
+ import type { Container, LoroList, LoroMovableList } from "loro-crdt"
2
+ import { convertInputToNode } from "../conversion.js"
3
+ import type {
4
+ ContainerOrValueShape,
5
+ ContainerShape,
6
+ ListContainerShape,
7
+ MovableListContainerShape,
8
+ } from "../shape.js"
9
+ import { isContainer, isValueShape } from "../utils/type-guards.js"
10
+ import { DraftNode, type DraftNodeParams } from "./base.js"
11
+ import { createContainerDraftNode } from "./utils.js"
12
+
13
+ // Shared logic for list operations
14
+ export abstract class ListDraftNodeBase<
15
+ NestedShape extends ContainerOrValueShape,
16
+ Item = NestedShape["_plain"],
17
+ DraftItem = NestedShape["_draft"],
18
+ > extends DraftNode<any> {
19
+ // Cache for items returned by array methods to track mutations
20
+ private itemCache = new Map<number, any>()
21
+
22
+ protected get container(): LoroList | LoroMovableList {
23
+ return super.container as LoroList | LoroMovableList
24
+ }
25
+
26
+ protected get shape():
27
+ | ListContainerShape<NestedShape>
28
+ | MovableListContainerShape<NestedShape> {
29
+ return super.shape as
30
+ | ListContainerShape<NestedShape>
31
+ | MovableListContainerShape<NestedShape>
32
+ }
33
+
34
+ absorbPlainValues() {
35
+ // Critical function: absorb mutated plain values back into Loro containers
36
+ // This is called at the end of change() to persist mutations made to plain objects
37
+ for (const [index, cachedItem] of this.itemCache.entries()) {
38
+ if (cachedItem) {
39
+ if (isValueShape(this.shape.shape)) {
40
+ // For value shapes, delegate to subclass-specific absorption logic
41
+ this.absorbValueAtIndex(index, cachedItem)
42
+ } else {
43
+ // For container shapes, the item should be a draft node that handles its own absorption
44
+ if (
45
+ cachedItem &&
46
+ typeof cachedItem === "object" &&
47
+ "absorbPlainValues" in cachedItem
48
+ ) {
49
+ ;(cachedItem as any).absorbPlainValues()
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ // Clear the cache after absorbing values
56
+ this.itemCache.clear()
57
+ }
58
+
59
+ // Abstract method to be implemented by subclasses
60
+ // Each subclass knows how to handle its specific container type
61
+ protected abstract absorbValueAtIndex(index: number, value: any): void
62
+
63
+ protected insertWithConversion(index: number, item: Item): void {
64
+ const convertedItem = convertInputToNode(item as any, this.shape.shape)
65
+ if (isContainer(convertedItem)) {
66
+ this.container.insertContainer(index, convertedItem)
67
+ } else {
68
+ this.container.insert(index, convertedItem)
69
+ }
70
+ }
71
+
72
+ protected pushWithConversion(item: Item): void {
73
+ const convertedItem = convertInputToNode(item as any, this.shape.shape)
74
+ if (isContainer(convertedItem)) {
75
+ this.container.pushContainer(convertedItem)
76
+ } else {
77
+ this.container.push(convertedItem)
78
+ }
79
+ }
80
+
81
+ getDraftNodeParams(
82
+ index: number,
83
+ shape: ContainerShape,
84
+ ): DraftNodeParams<ContainerShape> {
85
+ return {
86
+ shape,
87
+ emptyState: undefined, // List items don't have empty state
88
+ getContainer: () => {
89
+ const containerItem = this.container.get(index)
90
+ if (!containerItem || !isContainer(containerItem)) {
91
+ throw new Error(`No container found at index ${index}`)
92
+ }
93
+ return containerItem
94
+ },
95
+ }
96
+ }
97
+
98
+ // Get item for predicate functions - always returns plain Item for filtering logic
99
+ protected getPredicateItem(index: number): Item {
100
+ // CRITICAL FIX: For predicates to work correctly with mutations,
101
+ // we need to check if there's a cached (mutated) version first
102
+ const cachedItem = this.itemCache.get(index)
103
+ if (cachedItem && isValueShape(this.shape.shape)) {
104
+ // For value shapes, if we have a cached item, use it so predicates see mutations
105
+ return cachedItem as Item
106
+ }
107
+
108
+ const containerItem = this.container.get(index)
109
+ if (containerItem === undefined) {
110
+ return undefined as Item
111
+ }
112
+
113
+ if (isValueShape(this.shape.shape)) {
114
+ // For value shapes, return the plain value directly
115
+ return containerItem as Item
116
+ } else {
117
+ // For container shapes, we need to return the plain object representation
118
+ // This allows predicates to access nested properties like article.metadata.author
119
+ if (isContainer(containerItem)) {
120
+ // Convert container to plain object for predicate logic
121
+ // Handle different container types that may not have toJSON method
122
+ if (
123
+ typeof containerItem === "object" &&
124
+ containerItem !== null &&
125
+ "toJSON" in containerItem
126
+ ) {
127
+ return (containerItem as any).toJSON() as Item
128
+ } else if (
129
+ typeof containerItem === "object" &&
130
+ containerItem !== null &&
131
+ "getShallowValue" in containerItem
132
+ ) {
133
+ // For containers like LoroCounter that don't have toJSON but have getShallowValue
134
+ return (containerItem as any).getShallowValue() as Item
135
+ } else {
136
+ // Fallback for other container types
137
+ return containerItem as Item
138
+ }
139
+ }
140
+ return containerItem as Item
141
+ }
142
+ }
143
+
144
+ // Get item for return values - returns DraftItem that can be mutated
145
+ protected getDraftItem(index: number): DraftItem {
146
+ // Check if we already have a cached item for this index
147
+ let cachedItem = this.itemCache.get(index)
148
+ if (cachedItem) {
149
+ return cachedItem
150
+ }
151
+
152
+ // Get the raw container item
153
+ const containerItem = this.container.get(index)
154
+ if (containerItem === undefined) {
155
+ return undefined as DraftItem
156
+ }
157
+
158
+ if (isValueShape(this.shape.shape)) {
159
+ // For value shapes, we need to ensure mutations persist
160
+ // The key insight: we must return the SAME object for the same index
161
+ // so that mutations to filtered/found items persist back to the cache
162
+ if (typeof containerItem === "object" && containerItem !== null) {
163
+ // Create a deep copy for objects so mutations can be tracked
164
+ // IMPORTANT: Only create the copy once, then always return the same cached object
165
+ cachedItem = JSON.parse(JSON.stringify(containerItem))
166
+ } else {
167
+ // For primitives, just use the value directly
168
+ cachedItem = containerItem
169
+ }
170
+ this.itemCache.set(index, cachedItem)
171
+ return cachedItem as DraftItem
172
+ } else {
173
+ // For container shapes, create a proper draft node using the new pattern
174
+ cachedItem = createContainerDraftNode(
175
+ this.getDraftNodeParams(index, this.shape.shape as ContainerShape),
176
+ )
177
+ this.itemCache.set(index, cachedItem)
178
+ return cachedItem as DraftItem
179
+ }
180
+ }
181
+
182
+ // Array-like methods for better developer experience
183
+ // DUAL INTERFACE: Predicates get Item (plain data), return values are DraftItem (mutable)
184
+
185
+ find(
186
+ predicate: (item: Item, index: number) => boolean,
187
+ ): DraftItem | undefined {
188
+ for (let i = 0; i < this.length; i++) {
189
+ const predicateItem = this.getPredicateItem(i)
190
+ if (predicate(predicateItem, i)) {
191
+ return this.getDraftItem(i) // Return mutable draft item
192
+ }
193
+ }
194
+ return undefined
195
+ }
196
+
197
+ findIndex(predicate: (item: Item, index: number) => boolean): number {
198
+ for (let i = 0; i < this.length; i++) {
199
+ const predicateItem = this.getPredicateItem(i)
200
+ if (predicate(predicateItem, i)) {
201
+ return i
202
+ }
203
+ }
204
+ return -1
205
+ }
206
+
207
+ map<ReturnType>(
208
+ callback: (item: Item, index: number) => ReturnType,
209
+ ): ReturnType[] {
210
+ const result: ReturnType[] = []
211
+ for (let i = 0; i < this.length; i++) {
212
+ const predicateItem = this.getPredicateItem(i)
213
+ result.push(callback(predicateItem, i))
214
+ }
215
+ return result
216
+ }
217
+
218
+ filter(predicate: (item: Item, index: number) => boolean): DraftItem[] {
219
+ const result: DraftItem[] = []
220
+ for (let i = 0; i < this.length; i++) {
221
+ const predicateItem = this.getPredicateItem(i)
222
+ if (predicate(predicateItem, i)) {
223
+ result.push(this.getDraftItem(i)) // Return mutable draft items
224
+ }
225
+ }
226
+ return result
227
+ }
228
+
229
+ forEach(callback: (item: Item, index: number) => void): void {
230
+ for (let i = 0; i < this.length; i++) {
231
+ const predicateItem = this.getPredicateItem(i)
232
+ callback(predicateItem, i)
233
+ }
234
+ }
235
+
236
+ some(predicate: (item: Item, index: number) => boolean): boolean {
237
+ for (let i = 0; i < this.length; i++) {
238
+ const predicateItem = this.getPredicateItem(i)
239
+ if (predicate(predicateItem, i)) {
240
+ return true
241
+ }
242
+ }
243
+ return false
244
+ }
245
+
246
+ every(predicate: (item: Item, index: number) => boolean): boolean {
247
+ for (let i = 0; i < this.length; i++) {
248
+ const predicateItem = this.getPredicateItem(i)
249
+ if (!predicate(predicateItem, i)) {
250
+ return false
251
+ }
252
+ }
253
+ return true
254
+ }
255
+
256
+ insert(index: number, item: Item): void {
257
+ // Update cache indices before performing the insert operation
258
+ this.updateCacheForInsert(index)
259
+ this.insertWithConversion(index, item)
260
+ }
261
+
262
+ delete(index: number, len: number): void {
263
+ // Update cache indices before performing the delete operation
264
+ this.updateCacheForDelete(index, len)
265
+ this.container.delete(index, len)
266
+ }
267
+
268
+ push(item: Item): void {
269
+ this.pushWithConversion(item)
270
+ }
271
+
272
+ pushContainer(container: Container): Container {
273
+ return this.container.pushContainer(container)
274
+ }
275
+
276
+ insertContainer(index: number, container: Container): Container {
277
+ return this.container.insertContainer(index, container)
278
+ }
279
+
280
+ get(index: number): DraftItem {
281
+ return this.getDraftItem(index)
282
+ }
283
+
284
+ toArray(): Item[] {
285
+ return this.container.toArray() as Item[]
286
+ }
287
+
288
+ get length(): number {
289
+ return this.container.length
290
+ }
291
+
292
+ // Update cache indices when items are deleted
293
+ private updateCacheForDelete(deleteIndex: number, deleteLen: number): void {
294
+ const newCache = new Map<number, any>()
295
+
296
+ for (const [cachedIndex, cachedItem] of this.itemCache.entries()) {
297
+ if (cachedIndex < deleteIndex) {
298
+ // Items before the deletion point keep their indices
299
+ newCache.set(cachedIndex, cachedItem)
300
+ } else if (cachedIndex >= deleteIndex + deleteLen) {
301
+ // Items after the deletion range shift down by deleteLen
302
+ newCache.set(cachedIndex - deleteLen, cachedItem)
303
+ }
304
+ // Items within the deletion range are removed from cache
305
+ }
306
+
307
+ this.itemCache = newCache
308
+ }
309
+
310
+ // Update cache indices when items are inserted
311
+ private updateCacheForInsert(insertIndex: number): void {
312
+ const newCache = new Map<number, any>()
313
+
314
+ for (const [cachedIndex, cachedItem] of this.itemCache.entries()) {
315
+ if (cachedIndex < insertIndex) {
316
+ // Items before the insertion point keep their indices
317
+ newCache.set(cachedIndex, cachedItem)
318
+ } else {
319
+ // Items at or after the insertion point shift up by 1
320
+ newCache.set(cachedIndex + 1, cachedItem)
321
+ }
322
+ }
323
+
324
+ this.itemCache = newCache
325
+ }
326
+ }
@@ -0,0 +1,18 @@
1
+ import type { LoroList } from "loro-crdt"
2
+ import type { ContainerOrValueShape } from "../shape.js"
3
+ import { ListDraftNodeBase } from "./list-base.js"
4
+
5
+ // List draft node
6
+ export class ListDraftNode<
7
+ NestedShape extends ContainerOrValueShape,
8
+ > extends ListDraftNodeBase<NestedShape> {
9
+ protected get container(): LoroList {
10
+ return super.container as LoroList
11
+ }
12
+
13
+ protected absorbValueAtIndex(index: number, value: any): void {
14
+ // LoroList doesn't have set method, need to delete and insert
15
+ this.container.delete(index, 1)
16
+ this.container.insert(index, value)
17
+ }
18
+ }