@loro-extended/change 0.9.0 → 0.9.1

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.
@@ -1,13 +1,5 @@
1
- import {
2
- type Container,
3
- LoroCounter,
4
- LoroList,
5
- LoroMap,
6
- LoroMovableList,
7
- LoroText,
8
- LoroTree,
9
- type Value,
10
- } from "loro-crdt"
1
+ import type { Container, LoroMap, Value } from "loro-crdt"
2
+ import { mergeValue } from "../overlay.js"
11
3
  import type {
12
4
  ContainerOrValueShape,
13
5
  ContainerShape,
@@ -16,17 +8,14 @@ import type {
16
8
  } from "../shape.js"
17
9
  import { isContainerShape, isValueShape } from "../utils/type-guards.js"
18
10
  import { TypedRef, type TypedRefParams } from "./base.js"
19
- import { assignPlainValueToTypedRef, createContainerTypedRef } from "./utils.js"
20
-
21
- const containerConstructor = {
22
- counter: LoroCounter,
23
- list: LoroList,
24
- map: LoroMap,
25
- movableList: LoroMovableList,
26
- record: LoroMap,
27
- text: LoroText,
28
- tree: LoroTree,
29
- } as const
11
+ import {
12
+ absorbCachedPlainValues,
13
+ assignPlainValueToTypedRef,
14
+ containerConstructor,
15
+ createContainerTypedRef,
16
+ serializeRefToJSON,
17
+ unwrapReadonlyPrimitive,
18
+ } from "./utils.js"
30
19
 
31
20
  // Map typed ref
32
21
  export class MapRef<
@@ -48,16 +37,7 @@ export class MapRef<
48
37
  }
49
38
 
50
39
  absorbPlainValues() {
51
- for (const [key, node] of this.propertyCache.entries()) {
52
- if (node instanceof TypedRef) {
53
- // Contains a TypedRef, not a plain Value: keep recursing
54
- node.absorbPlainValues()
55
- continue
56
- }
57
-
58
- // Plain value!
59
- this.container.set(key, node)
60
- }
40
+ absorbCachedPlainValues(this.propertyCache, () => this.container)
61
41
  }
62
42
 
63
43
  getTypedRefParams<S extends ContainerShape>(
@@ -77,37 +57,37 @@ export class MapRef<
77
57
  }
78
58
  }
79
59
 
80
- getOrCreateNode<Shape extends ContainerShape | ValueShape>(
60
+ getOrCreateRef<Shape extends ContainerShape | ValueShape>(
81
61
  key: string,
82
62
  shape: Shape,
83
63
  ): any {
84
- let node = this.propertyCache.get(key)
85
- if (!node) {
64
+ let ref = this.propertyCache.get(key)
65
+ if (!ref) {
86
66
  if (isContainerShape(shape)) {
87
- node = createContainerTypedRef(this.getTypedRefParams(key, shape))
88
- // We cache container nodes even in readonly mode because they are just handles
89
- this.propertyCache.set(key, node)
67
+ ref = createContainerTypedRef(this.getTypedRefParams(key, shape))
68
+ // We cache container refs even in readonly mode because they are just handles
69
+ this.propertyCache.set(key, ref)
90
70
  } else {
91
71
  // For value shapes, first try to get the value from the container
92
72
  const containerValue = this.container.get(key)
93
73
  if (containerValue !== undefined) {
94
- node = containerValue as Value
74
+ ref = containerValue as Value
95
75
  } else {
96
76
  // Only fall back to placeholder if the container doesn't have the value
97
77
  const placeholder = (this.placeholder as any)?.[key]
98
78
  if (placeholder === undefined) {
99
79
  throw new Error("placeholder required")
100
80
  }
101
- node = placeholder as Value
81
+ ref = placeholder as Value
102
82
  }
103
83
 
104
84
  // In readonly mode, we DO NOT cache primitive values.
105
85
  // This ensures we always get the latest value from the CRDT on next access.
106
86
  if (!this.readonly) {
107
- this.propertyCache.set(key, node)
87
+ this.propertyCache.set(key, ref)
108
88
  }
109
89
  }
110
- if (node === undefined) throw new Error("no container made")
90
+ if (ref === undefined) throw new Error("no container made")
111
91
  }
112
92
 
113
93
  if (this.readonly && isContainerShape(shape)) {
@@ -118,32 +98,27 @@ export class MapRef<
118
98
  return (this.placeholder as any)?.[key]
119
99
  }
120
100
 
121
- if (shape._type === "counter") {
122
- return (node as any).value
123
- }
124
- if (shape._type === "text") {
125
- return (node as any).toString()
126
- }
101
+ return unwrapReadonlyPrimitive(ref as TypedRef<any>, shape)
127
102
  }
128
103
 
129
- return node as Shape extends ContainerShape ? TypedRef<Shape> : Value
104
+ return ref as Shape extends ContainerShape ? TypedRef<Shape> : Value
130
105
  }
131
106
 
132
107
  private createLazyProperties(): void {
133
108
  for (const key in this.shape.shapes) {
134
109
  const shape = this.shape.shapes[key]
135
110
  Object.defineProperty(this, key, {
136
- get: () => this.getOrCreateNode(key, shape),
111
+ get: () => this.getOrCreateRef(key, shape),
137
112
  set: value => {
138
- if (this.readonly) throw new Error("Cannot modify readonly ref")
113
+ this.assertMutable()
139
114
  if (isValueShape(shape)) {
140
115
  this.container.set(key, value)
141
116
  this.propertyCache.set(key, value)
142
117
  } else {
143
118
  if (value && typeof value === "object") {
144
- const node = this.getOrCreateNode(key, shape)
119
+ const ref = this.getOrCreateRef(key, shape)
145
120
 
146
- if (assignPlainValueToTypedRef(node as TypedRef<any>, value)) {
121
+ if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
147
122
  return
148
123
  }
149
124
  }
@@ -158,35 +133,33 @@ export class MapRef<
158
133
  }
159
134
 
160
135
  toJSON(): any {
161
- const result: any = {}
162
- for (const key in this.shape.shapes) {
163
- const value = (this as any)[key]
164
- if (value && typeof value === "object" && "toJSON" in value) {
165
- result[key] = value.toJSON()
166
- } else {
167
- result[key] = value
168
- }
136
+ // Fast path: readonly mode
137
+ if (this.readonly) {
138
+ const nativeJson = this.container.toJSON() as Value
139
+ // Overlay placeholders for missing properties
140
+ return mergeValue(this.shape, nativeJson, this.placeholder as Value)
169
141
  }
170
- return result
142
+
143
+ return serializeRefToJSON(this as any, Object.keys(this.shape.shapes))
171
144
  }
172
145
 
173
- // TOOD(duane): return correct type here
146
+ // TODO(duane): return correct type here
174
147
  get(key: string): any {
175
148
  return this.container.get(key)
176
149
  }
177
150
 
178
151
  set(key: string, value: Value): void {
179
- if (this.readonly) throw new Error("Cannot modify readonly ref")
152
+ this.assertMutable()
180
153
  this.container.set(key, value)
181
154
  }
182
155
 
183
156
  setContainer<C extends Container>(key: string, container: C): C {
184
- if (this.readonly) throw new Error("Cannot modify readonly ref")
157
+ this.assertMutable()
185
158
  return this.container.setContainer(key, container)
186
159
  }
187
160
 
188
161
  delete(key: string): void {
189
- if (this.readonly) throw new Error("Cannot modify readonly ref")
162
+ this.assertMutable()
190
163
  this.container.delete(key)
191
164
  }
192
165
 
@@ -20,12 +20,12 @@ export class MovableListRef<
20
20
  }
21
21
 
22
22
  move(from: number, to: number): void {
23
- if (this.readonly) throw new Error("Cannot modify readonly ref")
23
+ this.assertMutable()
24
24
  this.container.move(from, to)
25
25
  }
26
26
 
27
27
  set(index: number, item: Exclude<Item, Container>) {
28
- if (this.readonly) throw new Error("Cannot modify readonly ref")
28
+ this.assertMutable()
29
29
  return this.container.set(index, item)
30
30
  }
31
31
  }
@@ -11,8 +11,8 @@ describe("Record Types", () => {
11
11
  const doc = new TypedDoc(schema)
12
12
 
13
13
  doc.change(draft => {
14
- draft.scores.getOrCreateNode("alice").increment(10)
15
- draft.scores.getOrCreateNode("bob").increment(5)
14
+ draft.scores.getOrCreateRef("alice").increment(10)
15
+ draft.scores.getOrCreateRef("bob").increment(5)
16
16
  })
17
17
 
18
18
  expect(doc.toJSON().scores).toEqual({
@@ -21,7 +21,7 @@ describe("Record Types", () => {
21
21
  })
22
22
 
23
23
  doc.change(draft => {
24
- draft.scores.getOrCreateNode("alice").increment(5)
24
+ draft.scores.getOrCreateRef("alice").increment(5)
25
25
  draft.scores.delete("bob")
26
26
  })
27
27
 
@@ -38,8 +38,8 @@ describe("Record Types", () => {
38
38
  const doc = new TypedDoc(schema)
39
39
 
40
40
  doc.change(draft => {
41
- draft.notes.getOrCreateNode("todo").insert(0, "Buy milk")
42
- draft.notes.getOrCreateNode("reminders").insert(0, "Call mom")
41
+ draft.notes.getOrCreateRef("todo").insert(0, "Buy milk")
42
+ draft.notes.getOrCreateRef("reminders").insert(0, "Call mom")
43
43
  })
44
44
 
45
45
  expect(doc.toJSON().notes).toEqual({
@@ -56,11 +56,11 @@ describe("Record Types", () => {
56
56
  const doc = new TypedDoc(schema)
57
57
 
58
58
  doc.change(draft => {
59
- const groupA = draft.groups.getOrCreateNode("groupA")
59
+ const groupA = draft.groups.getOrCreateRef("groupA")
60
60
  groupA.push("alice")
61
61
  groupA.push("bob")
62
62
 
63
- const groupB = draft.groups.getOrCreateNode("groupB")
63
+ const groupB = draft.groups.getOrCreateRef("groupB")
64
64
  groupB.push("charlie")
65
65
  })
66
66
 
@@ -170,11 +170,11 @@ describe("Record Types", () => {
170
170
  const doc = new TypedDoc(schema)
171
171
 
172
172
  doc.change(draft => {
173
- const alice = draft.users.getOrCreateNode("u1")
173
+ const alice = draft.users.getOrCreateRef("u1")
174
174
  alice.name = "Alice"
175
175
  alice.age = 30
176
176
 
177
- const bob = draft.users.getOrCreateNode("u2")
177
+ const bob = draft.users.getOrCreateRef("u2")
178
178
  bob.name = "Bob"
179
179
  bob.age = 25
180
180
  })
@@ -1,40 +1,29 @@
1
- import {
2
- type Container,
3
- LoroCounter,
4
- LoroList,
5
- LoroMap,
6
- LoroMovableList,
7
- LoroText,
8
- LoroTree,
9
- type Value,
10
- } from "loro-crdt"
1
+ import type { Container, LoroMap, Value } from "loro-crdt"
11
2
  import { deriveShapePlaceholder } from "../derive-placeholder.js"
3
+ import { mergeValue } from "../overlay.js"
12
4
  import type {
13
5
  ContainerOrValueShape,
14
6
  ContainerShape,
15
7
  RecordContainerShape,
16
8
  } from "../shape.js"
17
- import type { Infer, InferDraftType } from "../types.js"
9
+ import type { Infer, InferMutableType } from "../types.js"
18
10
  import { isContainerShape, isValueShape } from "../utils/type-guards.js"
19
11
  import { TypedRef, type TypedRefParams } from "./base.js"
20
- import { assignPlainValueToTypedRef, createContainerTypedRef } from "./utils.js"
21
-
22
- const containerConstructor = {
23
- counter: LoroCounter,
24
- list: LoroList,
25
- map: LoroMap,
26
- movableList: LoroMovableList,
27
- record: LoroMap,
28
- text: LoroText,
29
- tree: LoroTree,
30
- } as const
12
+ import {
13
+ absorbCachedPlainValues,
14
+ assignPlainValueToTypedRef,
15
+ containerConstructor,
16
+ createContainerTypedRef,
17
+ serializeRefToJSON,
18
+ unwrapReadonlyPrimitive,
19
+ } from "./utils.js"
31
20
 
32
21
  // Record typed ref
33
22
  export class RecordRef<
34
23
  NestedShape extends ContainerOrValueShape,
35
24
  > extends TypedRef<any> {
36
25
  [key: string]: Infer<NestedShape> | any
37
- private nodeCache = new Map<string, TypedRef<ContainerShape> | Value>()
26
+ private refCache = new Map<string, TypedRef<ContainerShape> | Value>()
38
27
 
39
28
  protected get shape(): RecordContainerShape<NestedShape> {
40
29
  return super.shape as RecordContainerShape<NestedShape>
@@ -45,16 +34,7 @@ export class RecordRef<
45
34
  }
46
35
 
47
36
  absorbPlainValues() {
48
- for (const [key, node] of this.nodeCache.entries()) {
49
- if (node instanceof TypedRef) {
50
- // Contains a TypedRef, not a plain Value: keep recursing
51
- node.absorbPlainValues()
52
- continue
53
- }
54
-
55
- // Plain value!
56
- this.container.set(key, node)
57
- }
37
+ absorbCachedPlainValues(this.refCache, () => this.container)
58
38
  }
59
39
 
60
40
  getTypedRefParams<S extends ContainerShape>(
@@ -82,7 +62,7 @@ export class RecordRef<
82
62
  }
83
63
  }
84
64
 
85
- getOrCreateNode(key: string): any {
65
+ getOrCreateRef(key: string): any {
86
66
  // For readonly mode with container shapes, check if the key exists first
87
67
  // This allows optional chaining (?.) to work correctly for non-existent keys
88
68
  // Similar to how ListRefBase.getMutableItem() handles non-existent indices
@@ -93,67 +73,64 @@ export class RecordRef<
93
73
  }
94
74
  }
95
75
 
96
- let node = this.nodeCache.get(key)
97
- if (!node) {
76
+ let ref = this.refCache.get(key)
77
+ if (!ref) {
98
78
  const shape = this.shape.shape
99
79
  if (isContainerShape(shape)) {
100
- node = createContainerTypedRef(
80
+ ref = createContainerTypedRef(
101
81
  this.getTypedRefParams(key, shape as ContainerShape),
102
82
  )
103
- // Cache container nodes
104
- this.nodeCache.set(key, node)
83
+ // Cache container refs
84
+ this.refCache.set(key, ref)
105
85
  } else {
106
86
  // For value shapes, first try to get the value from the container
107
87
  const containerValue = this.container.get(key)
108
88
  if (containerValue !== undefined) {
109
- node = containerValue as Value
89
+ ref = containerValue as Value
110
90
  } else {
111
91
  // Only fall back to placeholder if the container doesn't have the value
112
92
  const placeholder = (this.placeholder as any)?.[key]
113
93
  if (placeholder === undefined) {
114
94
  // If it's a value type and not in container or placeholder,
115
95
  // fallback to the default value from the shape
116
- node = (shape as any)._plain
96
+ ref = (shape as any)._plain
117
97
  } else {
118
- node = placeholder as Value
98
+ ref = placeholder as Value
119
99
  }
120
100
  }
121
101
  // Only cache primitive values if NOT readonly
122
- if (node !== undefined && !this.readonly) {
123
- this.nodeCache.set(key, node)
102
+ if (ref !== undefined && !this.readonly) {
103
+ this.refCache.set(key, ref)
124
104
  }
125
105
  }
126
106
  }
127
107
 
128
108
  if (this.readonly && isContainerShape(this.shape.shape)) {
129
- const shape = this.shape.shape as ContainerShape
130
- if (shape._type === "counter") {
131
- return (node as any).value
132
- }
133
- if (shape._type === "text") {
134
- return (node as any).toString()
135
- }
109
+ return unwrapReadonlyPrimitive(
110
+ ref as TypedRef<any>,
111
+ this.shape.shape as ContainerShape,
112
+ )
136
113
  }
137
114
 
138
- return node as any
115
+ return ref as any
139
116
  }
140
117
 
141
- get(key: string): InferDraftType<NestedShape> {
142
- return this.getOrCreateNode(key)
118
+ get(key: string): InferMutableType<NestedShape> {
119
+ return this.getOrCreateRef(key)
143
120
  }
144
121
 
145
122
  set(key: string, value: any): void {
146
- if (this.readonly) throw new Error("Cannot modify readonly ref")
123
+ this.assertMutable()
147
124
  if (isValueShape(this.shape.shape)) {
148
125
  this.container.set(key, value)
149
- this.nodeCache.set(key, value)
126
+ this.refCache.set(key, value)
150
127
  } else {
151
128
  // For containers, we can't set them directly usually.
152
129
  // But if the user passes a plain object that matches the shape, maybe we should convert it?
153
130
  if (value && typeof value === "object") {
154
- const node = this.getOrCreateNode(key)
131
+ const ref = this.getOrCreateRef(key)
155
132
 
156
- if (assignPlainValueToTypedRef(node, value)) {
133
+ if (assignPlainValueToTypedRef(ref, value)) {
157
134
  return
158
135
  }
159
136
  }
@@ -165,14 +142,14 @@ export class RecordRef<
165
142
  }
166
143
 
167
144
  setContainer<C extends Container>(key: string, container: C): C {
168
- if (this.readonly) throw new Error("Cannot modify readonly ref")
145
+ this.assertMutable()
169
146
  return this.container.setContainer(key, container)
170
147
  }
171
148
 
172
149
  delete(key: string): void {
173
- if (this.readonly) throw new Error("Cannot modify readonly ref")
150
+ this.assertMutable()
174
151
  this.container.delete(key)
175
- this.nodeCache.delete(key)
152
+ this.refCache.delete(key)
176
153
  }
177
154
 
178
155
  has(key: string): boolean {
@@ -192,15 +169,25 @@ export class RecordRef<
192
169
  }
193
170
 
194
171
  toJSON(): Record<string, any> {
195
- const result: Record<string, any> = {}
196
- for (const key of this.keys()) {
197
- const value = this.get(key)
198
- if (value && typeof value === "object" && "toJSON" in value) {
199
- result[key] = (value as any).toJSON()
200
- } else {
201
- result[key] = value
172
+ // Fast path: readonly mode
173
+ if (this.readonly) {
174
+ const nativeJson = this.container.toJSON() as Record<string, any>
175
+ // For records, we need to overlay placeholders for each entry's nested shape
176
+ const result: Record<string, any> = {}
177
+ for (const key of Object.keys(nativeJson)) {
178
+ // For records, the placeholder is always {}, so we need to derive
179
+ // the placeholder for the nested shape on the fly
180
+ const nestedPlaceholderValue = deriveShapePlaceholder(this.shape.shape)
181
+
182
+ result[key] = mergeValue(
183
+ this.shape.shape,
184
+ nativeJson[key],
185
+ nestedPlaceholderValue as Value,
186
+ )
202
187
  }
188
+ return result
203
189
  }
204
- return result
190
+
191
+ return serializeRefToJSON(this, this.keys())
205
192
  }
206
193
  }
@@ -9,12 +9,12 @@ export class TextRef extends TypedRef<TextContainerShape> {
9
9
 
10
10
  // Text methods
11
11
  insert(index: number, content: string): void {
12
- if (this.readonly) throw new Error("Cannot modify readonly ref")
12
+ this.assertMutable()
13
13
  this.container.insert(index, content)
14
14
  }
15
15
 
16
16
  delete(index: number, len: number): void {
17
- if (this.readonly) throw new Error("Cannot modify readonly ref")
17
+ this.assertMutable()
18
18
  this.container.delete(index, len)
19
19
  }
20
20
 
@@ -27,17 +27,17 @@ export class TextRef extends TypedRef<TextContainerShape> {
27
27
  }
28
28
 
29
29
  update(text: string): void {
30
- if (this.readonly) throw new Error("Cannot modify readonly ref")
30
+ this.assertMutable()
31
31
  this.container.update(text)
32
32
  }
33
33
 
34
34
  mark(range: { start: number; end: number }, key: string, value: any): void {
35
- if (this.readonly) throw new Error("Cannot modify readonly ref")
35
+ this.assertMutable()
36
36
  this.container.mark(range, key, value)
37
37
  }
38
38
 
39
39
  unmark(range: { start: number; end: number }, key: string): void {
40
- if (this.readonly) throw new Error("Cannot modify readonly ref")
40
+ this.assertMutable()
41
41
  this.container.unmark(range, key)
42
42
  }
43
43
 
@@ -46,7 +46,7 @@ export class TextRef extends TypedRef<TextContainerShape> {
46
46
  }
47
47
 
48
48
  applyDelta(delta: any[]): void {
49
- if (this.readonly) throw new Error("Cannot modify readonly ref")
49
+ this.assertMutable()
50
50
  this.container.applyDelta(delta)
51
51
  }
52
52
 
@@ -8,17 +8,17 @@ export class TreeRef<T extends TreeContainerShape> extends TypedRef<T> {
8
8
  }
9
9
 
10
10
  createNode(parent?: any, index?: number): any {
11
- if (this.readonly) throw new Error("Cannot modify readonly ref")
11
+ this.assertMutable()
12
12
  return this.container.createNode(parent, index)
13
13
  }
14
14
 
15
15
  move(target: any, parent?: any, index?: number): void {
16
- if (this.readonly) throw new Error("Cannot modify readonly ref")
16
+ this.assertMutable()
17
17
  this.container.move(target, parent, index)
18
18
  }
19
19
 
20
20
  delete(target: any): void {
21
- if (this.readonly) throw new Error("Cannot modify readonly ref")
21
+ this.assertMutable()
22
22
  this.container.delete(target)
23
23
  }
24
24