@loro-extended/change 3.0.0 → 5.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.
Files changed (61) hide show
  1. package/README.md +289 -150
  2. package/dist/index.d.ts +1012 -310
  3. package/dist/index.js +1334 -568
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -4
  6. package/src/change.test.ts +51 -52
  7. package/src/functional-helpers.test.ts +316 -4
  8. package/src/functional-helpers.ts +96 -6
  9. package/src/grand-unified-api.test.ts +35 -29
  10. package/src/index.ts +27 -1
  11. package/src/json-patch.test.ts +46 -27
  12. package/src/loro.test.ts +449 -0
  13. package/src/loro.ts +273 -0
  14. package/src/overlay-recursion.test.ts +1 -1
  15. package/src/overlay.ts +62 -3
  16. package/src/path-evaluator.ts +1 -1
  17. package/src/path-selector.test.ts +94 -1
  18. package/src/shape.ts +107 -14
  19. package/src/typed-doc.ts +100 -98
  20. package/src/typed-refs/base.ts +126 -46
  21. package/src/typed-refs/counter-ref-internals.ts +62 -0
  22. package/src/typed-refs/{counter.test.ts → counter-ref.test.ts} +5 -4
  23. package/src/typed-refs/counter-ref.ts +45 -0
  24. package/src/typed-refs/{doc.ts → doc-ref-internals.ts} +33 -56
  25. package/src/typed-refs/doc-ref.ts +47 -0
  26. package/src/typed-refs/encapsulation.test.ts +226 -0
  27. package/src/typed-refs/list-ref-base-internals.ts +280 -0
  28. package/src/typed-refs/list-ref-base.ts +518 -0
  29. package/src/typed-refs/list-ref-internals.ts +21 -0
  30. package/src/typed-refs/list-ref-value-updates.test.ts +213 -0
  31. package/src/typed-refs/{list.ts → list-ref.ts} +10 -11
  32. package/src/typed-refs/movable-list-ref-internals.ts +38 -0
  33. package/src/typed-refs/movable-list-ref.ts +31 -0
  34. package/src/typed-refs/proxy-handlers.ts +13 -4
  35. package/src/typed-refs/record-ref-internals.ts +216 -0
  36. package/src/typed-refs/record-ref-value-updates.test.ts +214 -0
  37. package/src/typed-refs/{record.test.ts → record-ref.test.ts} +21 -16
  38. package/src/typed-refs/record-ref.ts +80 -0
  39. package/src/typed-refs/struct-ref-internals.ts +195 -0
  40. package/src/typed-refs/struct-ref.test.ts +202 -0
  41. package/src/typed-refs/struct-ref.ts +257 -0
  42. package/src/typed-refs/text-ref-internals.ts +100 -0
  43. package/src/typed-refs/text-ref.ts +72 -0
  44. package/src/typed-refs/tree-node-ref-internals.ts +111 -0
  45. package/src/typed-refs/tree-node-ref.test.ts +234 -0
  46. package/src/typed-refs/tree-node-ref.ts +200 -0
  47. package/src/typed-refs/tree-node.test.ts +384 -0
  48. package/src/typed-refs/tree-ref-internals.ts +110 -0
  49. package/src/typed-refs/tree-ref.ts +194 -0
  50. package/src/typed-refs/utils.ts +38 -17
  51. package/src/types.ts +36 -1
  52. package/src/utils/type-guards.ts +1 -0
  53. package/src/typed-refs/counter.ts +0 -64
  54. package/src/typed-refs/list-base.ts +0 -424
  55. package/src/typed-refs/movable-list.ts +0 -34
  56. package/src/typed-refs/record.ts +0 -220
  57. package/src/typed-refs/struct.ts +0 -206
  58. package/src/typed-refs/text.ts +0 -97
  59. package/src/typed-refs/tree.ts +0 -40
  60. /package/src/typed-refs/{list.test.ts → list-ref.test.ts} +0 -0
  61. /package/src/typed-refs/{movable-list.test.ts → movable-list-ref.test.ts} +0 -0
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change, createTypedDoc, Shape } from "../index.js"
3
+
4
+ /**
5
+ * Tests for List value updates across multiple change() calls.
6
+ *
7
+ * ListRefBase has a different caching pattern than RecordRef/StructRef:
8
+ * - It caches items in itemCache
9
+ * - The cache is cleared in absorbPlainValues() after each change()
10
+ *
11
+ * However, there may still be stale cache issues if:
12
+ * 1. Items are accessed outside of change() (populating the cache)
13
+ * 2. Items are modified in a change() (different list instance)
14
+ * 3. Items are accessed again outside of change() (stale cache?)
15
+ *
16
+ * Note: Lists don't support direct item modification like records/structs.
17
+ * To "update" an item, you typically delete and re-insert, or modify
18
+ * nested container properties.
19
+ */
20
+ describe("List value updates across change() calls", () => {
21
+ describe("primitive value lists", () => {
22
+ it("reads updated values after delete and insert", () => {
23
+ const Schema = Shape.doc({
24
+ numbers: Shape.list(Shape.plain.number()),
25
+ })
26
+
27
+ const doc = createTypedDoc(Schema)
28
+
29
+ change(doc, draft => {
30
+ draft.numbers.push(100)
31
+ draft.numbers.push(200)
32
+ draft.numbers.push(300)
33
+ })
34
+ expect(doc.numbers.get(0)).toBe(100)
35
+ expect(doc.numbers.get(1)).toBe(200)
36
+ expect(doc.numbers.get(2)).toBe(300)
37
+
38
+ // Modify by deleting and inserting
39
+ change(doc, draft => {
40
+ draft.numbers.delete(1, 1) // Remove 200
41
+ draft.numbers.insert(1, 999) // Insert 999 at position 1
42
+ })
43
+ expect(doc.numbers.get(0)).toBe(100)
44
+ expect(doc.numbers.get(1)).toBe(999) // Should be 999, not 200
45
+ expect(doc.numbers.get(2)).toBe(300)
46
+ })
47
+
48
+ it("reads correct values after multiple push operations", () => {
49
+ const Schema = Shape.doc({
50
+ items: Shape.list(Shape.plain.string()),
51
+ })
52
+
53
+ const doc = createTypedDoc(Schema)
54
+
55
+ change(doc, draft => {
56
+ draft.items.push("first")
57
+ })
58
+ expect(doc.items.get(0)).toBe("first")
59
+
60
+ change(doc, draft => {
61
+ draft.items.push("second")
62
+ })
63
+ expect(doc.items.get(0)).toBe("first")
64
+ expect(doc.items.get(1)).toBe("second")
65
+
66
+ change(doc, draft => {
67
+ draft.items.push("third")
68
+ })
69
+ expect(doc.items.get(0)).toBe("first")
70
+ expect(doc.items.get(1)).toBe("second")
71
+ expect(doc.items.get(2)).toBe("third")
72
+ })
73
+ })
74
+
75
+ describe("object value lists", () => {
76
+ it("reads updated object values after modification", () => {
77
+ const Schema = Shape.doc({
78
+ items: Shape.list(
79
+ Shape.plain.object({
80
+ name: Shape.plain.string(),
81
+ value: Shape.plain.number(),
82
+ }),
83
+ ),
84
+ })
85
+
86
+ const doc = createTypedDoc(Schema)
87
+
88
+ change(doc, draft => {
89
+ draft.items.push({ name: "item1", value: 100 })
90
+ })
91
+
92
+ // Access item outside of change() - this may populate cache
93
+ const item0 = doc.items.get(0)
94
+ expect(item0?.name).toBe("item1")
95
+ expect(item0?.value).toBe(100)
96
+
97
+ // Modify by replacing the item
98
+ change(doc, draft => {
99
+ draft.items.delete(0, 1)
100
+ draft.items.insert(0, { name: "updated", value: 999 })
101
+ })
102
+
103
+ // Read again - should see updated values
104
+ const item0After = doc.items.get(0)
105
+ expect(item0After?.name).toBe("updated")
106
+ expect(item0After?.value).toBe(999)
107
+ })
108
+ })
109
+
110
+ describe("list of structs (container shapes)", () => {
111
+ it("reads updated struct properties after modification", () => {
112
+ const Schema = Shape.doc({
113
+ users: Shape.list(
114
+ Shape.struct({
115
+ name: Shape.plain.string(),
116
+ age: Shape.plain.number(),
117
+ }),
118
+ ),
119
+ })
120
+
121
+ const doc = createTypedDoc(Schema)
122
+
123
+ change(doc, draft => {
124
+ draft.users.push({ name: "Alice", age: 30 })
125
+ })
126
+
127
+ // Access outside of change()
128
+ expect(doc.users.get(0)?.name).toBe("Alice")
129
+ expect(doc.users.get(0)?.age).toBe(30)
130
+
131
+ // Modify the struct's properties in a new change()
132
+ change(doc, draft => {
133
+ const user = draft.users.get(0)
134
+ if (user) {
135
+ user.name = "Bob"
136
+ user.age = 25
137
+ }
138
+ })
139
+
140
+ // Read again - should see updated values
141
+ // This tests if the cached StructRef returns stale values
142
+ expect(doc.users.get(0)?.name).toBe("Bob") // May fail due to StructRef cache
143
+ expect(doc.users.get(0)?.age).toBe(25) // May fail due to StructRef cache
144
+ })
145
+
146
+ it("handles multiple updates to same struct in list", () => {
147
+ const Schema = Shape.doc({
148
+ items: Shape.list(
149
+ Shape.struct({
150
+ count: Shape.plain.number(),
151
+ }),
152
+ ),
153
+ })
154
+
155
+ const doc = createTypedDoc(Schema)
156
+
157
+ change(doc, draft => {
158
+ draft.items.push({ count: 0 })
159
+ })
160
+
161
+ // Multiple updates
162
+ for (let i = 1; i <= 5; i++) {
163
+ change(doc, draft => {
164
+ const item = draft.items.get(0)
165
+ if (item) {
166
+ item.count = i
167
+ }
168
+ })
169
+ expect(doc.items.get(0)?.count).toBe(i) // May fail on i > 1
170
+ }
171
+ })
172
+ })
173
+
174
+ describe("toJSON() consistency", () => {
175
+ it("reflects updates in toJSON()", () => {
176
+ const Schema = Shape.doc({
177
+ values: Shape.list(Shape.plain.number()),
178
+ })
179
+
180
+ const doc = createTypedDoc(Schema)
181
+
182
+ change(doc, draft => {
183
+ draft.values.push(1)
184
+ draft.values.push(2)
185
+ })
186
+ expect(doc.toJSON().values).toEqual([1, 2])
187
+
188
+ change(doc, draft => {
189
+ draft.values.delete(0, 1)
190
+ draft.values.insert(0, 99)
191
+ })
192
+ expect(doc.toJSON().values).toEqual([99, 2])
193
+ })
194
+ })
195
+
196
+ describe("comparison with raw LoroDoc", () => {
197
+ it("underlying CRDT operations work correctly", async () => {
198
+ const { LoroDoc } = await import("loro-crdt")
199
+
200
+ const doc = new LoroDoc()
201
+ const list = doc.getList("items")
202
+
203
+ list.push(100)
204
+ doc.commit()
205
+ expect(list.get(0)).toBe(100)
206
+
207
+ list.delete(0, 1)
208
+ list.insert(0, 999)
209
+ doc.commit()
210
+ expect(list.get(0)).toBe(999) // PASSES: raw Loro works fine
211
+ })
212
+ })
213
+ })
@@ -1,9 +1,12 @@
1
- import type { LoroList } from "loro-crdt"
2
1
  import type { ContainerOrValueShape } from "../shape.js"
3
2
  import type { InferMutableType } from "../types.js"
4
- import { ListRefBase } from "./list-base.js"
3
+ import type { TypedRefParams } from "./base.js"
4
+ import { ListRefBase } from "./list-ref-base.js"
5
+ import { ListRefInternals } from "./list-ref-internals.js"
5
6
 
6
- // List typed ref
7
+ /**
8
+ * List typed ref - thin facade that delegates to ListRefInternals.
9
+ */
7
10
  export class ListRef<
8
11
  NestedShape extends ContainerOrValueShape,
9
12
  > extends ListRefBase<NestedShape> {
@@ -12,13 +15,9 @@ export class ListRef<
12
15
  // TypeScript may require type assertions for plain value assignments.
13
16
  [index: number]: InferMutableType<NestedShape> | undefined
14
17
 
15
- protected get container(): LoroList {
16
- return super.container as LoroList
17
- }
18
-
19
- protected absorbValueAtIndex(index: number, value: any): void {
20
- // LoroList doesn't have set method, need to delete and insert
21
- this.container.delete(index, 1)
22
- this.container.insert(index, value)
18
+ protected override createInternals(
19
+ params: TypedRefParams<any>,
20
+ ): ListRefInternals<NestedShape> {
21
+ return new ListRefInternals(params)
23
22
  }
24
23
  }
@@ -0,0 +1,38 @@
1
+ import type { LoroMovableList } from "loro-crdt"
2
+ import type { ContainerOrValueShape } from "../shape.js"
3
+ import { ListRefBaseInternals } from "./list-ref-base.js"
4
+
5
+ // ============================================================================
6
+ // MovableListRefInternals - Internal implementation class
7
+ // ============================================================================
8
+
9
+ /**
10
+ * Internal implementation for MovableListRef.
11
+ * Extends ListRefBaseInternals with LoroMovableList-specific methods.
12
+ */
13
+ export class MovableListRefInternals<
14
+ NestedShape extends ContainerOrValueShape,
15
+ Item = NestedShape["_plain"],
16
+ MutableItem = NestedShape["_mutable"],
17
+ > extends ListRefBaseInternals<NestedShape, Item, MutableItem> {
18
+ /** Absorb value at specific index for LoroMovableList */
19
+ override absorbValueAtIndex(index: number, value: unknown): void {
20
+ // LoroMovableList has set method
21
+ const container = this.getContainer() as LoroMovableList
22
+ container.set(index, value)
23
+ }
24
+
25
+ /** Move an item from one index to another */
26
+ move(from: number, to: number): void {
27
+ const container = this.getContainer() as LoroMovableList
28
+ container.move(from, to)
29
+ this.commitIfAuto()
30
+ }
31
+
32
+ /** Set an item at a specific index */
33
+ set(index: number, item: unknown): void {
34
+ const container = this.getContainer() as LoroMovableList
35
+ container.set(index, item)
36
+ this.commitIfAuto()
37
+ }
38
+ }
@@ -0,0 +1,31 @@
1
+ import type { Container } from "loro-crdt"
2
+ import type { ContainerOrValueShape } from "../shape.js"
3
+ import type { InferMutableType } from "../types.js"
4
+ import { INTERNAL_SYMBOL, type TypedRefParams } from "./base.js"
5
+ import { ListRefBase } from "./list-ref-base.js"
6
+ import { MovableListRefInternals } from "./movable-list-ref-internals.js"
7
+
8
+ /**
9
+ * Movable list typed ref - thin facade that delegates to MovableListRefInternals.
10
+ */
11
+ export class MovableListRef<
12
+ NestedShape extends ContainerOrValueShape,
13
+ Item = NestedShape["_plain"],
14
+ > extends ListRefBase<NestedShape> {
15
+ declare [INTERNAL_SYMBOL]: MovableListRefInternals<NestedShape>;
16
+ [index: number]: InferMutableType<NestedShape> | undefined
17
+
18
+ protected override createInternals(
19
+ params: TypedRefParams<any>,
20
+ ): MovableListRefInternals<NestedShape> {
21
+ return new MovableListRefInternals(params)
22
+ }
23
+
24
+ move(from: number, to: number): void {
25
+ this[INTERNAL_SYMBOL].move(from, to)
26
+ }
27
+
28
+ set(index: number, item: Exclude<Item, Container>) {
29
+ this[INTERNAL_SYMBOL].set(index, item)
30
+ }
31
+ }
@@ -1,15 +1,18 @@
1
- import type { ListRef } from "./list.js"
2
- import type { MovableListRef } from "./movable-list.js"
3
- import type { RecordRef } from "./record.js"
1
+ import { INTERNAL_SYMBOL } from "./base.js"
2
+ import type { ListRef } from "./list-ref.js"
3
+ import type { MovableListRef } from "./movable-list-ref.js"
4
+ import type { RecordRef } from "./record-ref.js"
5
+ import type { RecordRefInternals } from "./record-ref-internals.js"
4
6
 
5
7
  export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
6
8
  get: (target, prop) => {
7
9
  if (typeof prop === "string" && !(prop in target)) {
8
10
  // Use getRef for reading - returns undefined for non-existent keys
9
- return target.getRef(prop)
11
+ return (target[INTERNAL_SYMBOL] as RecordRefInternals<any>).getRef(prop)
10
12
  }
11
13
  return Reflect.get(target, prop)
12
14
  },
15
+
13
16
  set: (target, prop, value) => {
14
17
  if (typeof prop === "string" && !(prop in target)) {
15
18
  target.set(prop, value)
@@ -17,6 +20,7 @@ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
17
20
  }
18
21
  return Reflect.set(target, prop, value)
19
22
  },
23
+
20
24
  deleteProperty: (target, prop) => {
21
25
  if (typeof prop === "string" && !(prop in target)) {
22
26
  target.delete(prop)
@@ -24,6 +28,7 @@ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
24
28
  }
25
29
  return Reflect.deleteProperty(target, prop)
26
30
  },
31
+
27
32
  // Support `in` operator for checking key existence
28
33
  has: (target, prop) => {
29
34
  if (typeof prop === "string") {
@@ -36,9 +41,11 @@ export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
36
41
  }
37
42
  return Reflect.has(target, prop)
38
43
  },
44
+
39
45
  ownKeys: target => {
40
46
  return target.keys()
41
47
  },
48
+
42
49
  getOwnPropertyDescriptor: (target, prop) => {
43
50
  if (typeof prop === "string" && target.has(prop)) {
44
51
  return {
@@ -61,6 +68,7 @@ export const listProxyHandler: ProxyHandler<ListRef<any>> = {
61
68
  }
62
69
  return Reflect.get(target, prop)
63
70
  },
71
+
64
72
  set: (target, prop, value) => {
65
73
  if (typeof prop === "string") {
66
74
  const index = Number(prop)
@@ -85,6 +93,7 @@ export const movableListProxyHandler: ProxyHandler<MovableListRef<any>> = {
85
93
  }
86
94
  return Reflect.get(target, prop)
87
95
  },
96
+
88
97
  set: (target, prop, value) => {
89
98
  if (typeof prop === "string") {
90
99
  const index = Number(prop)
@@ -0,0 +1,216 @@
1
+ import type {
2
+ Container,
3
+ LoroDoc,
4
+ LoroMap,
5
+ Subscription,
6
+ Value,
7
+ } from "loro-crdt"
8
+ import { deriveShapePlaceholder } from "../derive-placeholder.js"
9
+ import type { LoroMapRef } from "../loro.js"
10
+ import type {
11
+ ContainerOrValueShape,
12
+ ContainerShape,
13
+ RecordContainerShape,
14
+ } from "../shape.js"
15
+ import { isContainerShape, isValueShape } from "../utils/type-guards.js"
16
+ import { BaseRefInternals, type TypedRef, type TypedRefParams } from "./base.js"
17
+ import {
18
+ absorbCachedPlainValues,
19
+ assignPlainValueToTypedRef,
20
+ containerConstructor,
21
+ createContainerTypedRef,
22
+ hasContainerConstructor,
23
+ } from "./utils.js"
24
+
25
+ /**
26
+ * Internal implementation for RecordRef.
27
+ * Contains all logic, state, and implementation details.
28
+ */
29
+ export class RecordRefInternals<
30
+ NestedShape extends ContainerOrValueShape,
31
+ > extends BaseRefInternals<any> {
32
+ private refCache = new Map<string, TypedRef<ContainerShape> | Value>()
33
+
34
+ /** Get typed ref params for creating child refs at a key */
35
+ getTypedRefParams(
36
+ key: string,
37
+ shape: ContainerShape,
38
+ ): TypedRefParams<ContainerShape> {
39
+ // First try to get placeholder from the Record's placeholder (if it has an entry for this key)
40
+ let placeholder = (this.getPlaceholder() as any)?.[key]
41
+
42
+ // If no placeholder exists for this key, derive one from the schema's shape
43
+ // This is critical for Records where the placeholder is always {} but nested
44
+ // containers need valid placeholders to fall back to for missing values
45
+ if (placeholder === undefined) {
46
+ placeholder = deriveShapePlaceholder(shape)
47
+ }
48
+
49
+ // AnyContainerShape is an escape hatch - it doesn't have a constructor
50
+ if (!hasContainerConstructor(shape._type)) {
51
+ throw new Error(
52
+ `Cannot create typed ref for shape type "${shape._type}". ` +
53
+ `Use Shape.any() only at the document root level.`,
54
+ )
55
+ }
56
+
57
+ const LoroContainer = containerConstructor[shape._type]
58
+ const container = this.getContainer() as LoroMap
59
+
60
+ return {
61
+ shape,
62
+ placeholder,
63
+ getContainer: () =>
64
+ container.getOrCreateContainer(key, new (LoroContainer as any)()),
65
+ autoCommit: this.getAutoCommit(),
66
+ batchedMutation: this.getBatchedMutation(),
67
+ getDoc: () => this.getDoc(),
68
+ }
69
+ }
70
+
71
+ /** Get a ref for a key without creating (returns undefined for non-existent container keys) */
72
+ getRef(key: string): unknown {
73
+ const recordShape = this.getShape() as RecordContainerShape<NestedShape>
74
+ const shape = recordShape.shape
75
+ const container = this.getContainer() as LoroMap
76
+
77
+ // For container shapes, check if the key exists first
78
+ // This allows optional chaining (?.) to work correctly for non-existent keys
79
+ if (isContainerShape(shape)) {
80
+ const existing = container.get(key)
81
+ if (existing === undefined) {
82
+ return undefined
83
+ }
84
+ }
85
+
86
+ return this.getOrCreateRef(key)
87
+ }
88
+
89
+ /** Get or create a ref for a key (always creates for container shapes) */
90
+ getOrCreateRef(key: string): unknown {
91
+ const recordShape = this.getShape() as RecordContainerShape<NestedShape>
92
+ const shape = recordShape.shape
93
+ const container = this.getContainer() as LoroMap
94
+
95
+ if (isValueShape(shape)) {
96
+ // When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
97
+ // from container (NEVER cache). This ensures we always get the latest value
98
+ // from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
99
+ //
100
+ // When in batchedMutation mode (inside change()), we cache value shapes so that
101
+ // mutations to nested objects persist back to the CRDT via absorbPlainValues()
102
+ if (!this.getBatchedMutation()) {
103
+ const containerValue = container.get(key)
104
+ if (containerValue !== undefined) {
105
+ return containerValue
106
+ }
107
+ // Fall back to placeholder if the container doesn't have the value
108
+ const placeholder = (this.getPlaceholder() as any)?.[key]
109
+ if (placeholder !== undefined) {
110
+ return placeholder
111
+ }
112
+ // Fall back to the default value from the shape
113
+ return (shape as any)._plain
114
+ }
115
+
116
+ // In batched mode (within change()), we cache value shapes so that
117
+ // mutations to nested objects persist back to the CRDT via absorbPlainValues()
118
+ let ref = this.refCache.get(key)
119
+ if (!ref) {
120
+ const containerValue = container.get(key)
121
+ if (containerValue !== undefined) {
122
+ // For objects, create a deep copy so mutations can be tracked
123
+ if (typeof containerValue === "object" && containerValue !== null) {
124
+ ref = JSON.parse(JSON.stringify(containerValue))
125
+ } else {
126
+ ref = containerValue as Value
127
+ }
128
+ } else {
129
+ // Fall back to placeholder if the container doesn't have the value
130
+ const placeholder = (this.getPlaceholder() as any)?.[key]
131
+ if (placeholder !== undefined) {
132
+ ref = placeholder as Value
133
+ } else {
134
+ // Fall back to the default value from the shape
135
+ ref = (shape as any)._plain
136
+ }
137
+ }
138
+ this.refCache.set(key, ref)
139
+ }
140
+ return ref
141
+ }
142
+
143
+ // For container shapes, we can safely cache the ref since it's a handle
144
+ // to the underlying Loro container, not a value copy.
145
+ let ref = this.refCache.get(key)
146
+ if (!ref) {
147
+ ref = createContainerTypedRef(
148
+ this.getTypedRefParams(key, shape as ContainerShape),
149
+ )
150
+ this.refCache.set(key, ref)
151
+ }
152
+
153
+ return ref as any
154
+ }
155
+
156
+ /** Set a value at a key */
157
+ set(key: string, value: any): void {
158
+ const recordShape = this.getShape() as RecordContainerShape<NestedShape>
159
+ const shape = recordShape.shape
160
+ const container = this.getContainer() as LoroMap
161
+
162
+ if (isValueShape(shape)) {
163
+ container.set(key, value)
164
+ this.refCache.set(key, value)
165
+ this.commitIfAuto()
166
+ } else {
167
+ // For container shapes, try to assign the plain value
168
+ // Use getOrCreateRef to ensure the container is created
169
+ const ref = this.getOrCreateRef(key)
170
+ if (assignPlainValueToTypedRef(ref as TypedRef<any>, value)) {
171
+ this.commitIfAuto()
172
+ return
173
+ }
174
+ throw new Error(
175
+ "Cannot set container directly, modify the typed ref instead",
176
+ )
177
+ }
178
+ }
179
+
180
+ /** Delete a key */
181
+ delete(key: string): void {
182
+ const container = this.getContainer() as LoroMap
183
+ container.delete(key)
184
+ this.refCache.delete(key)
185
+ this.commitIfAuto()
186
+ }
187
+
188
+ /** Absorb mutated plain values back into Loro containers */
189
+ absorbPlainValues(): void {
190
+ absorbCachedPlainValues(this.refCache, () => this.getContainer() as LoroMap)
191
+ }
192
+
193
+ /** Create the loro namespace for record */
194
+ protected override createLoroNamespace(): LoroMapRef {
195
+ const self = this
196
+ return {
197
+ get doc(): LoroDoc {
198
+ return self.getDoc()
199
+ },
200
+ get container(): LoroMap {
201
+ return self.getContainer() as LoroMap
202
+ },
203
+ subscribe(callback: (event: unknown) => void): Subscription {
204
+ return (self.getContainer() as LoroMap).subscribe(callback)
205
+ },
206
+ setContainer(key: string, container: Container): Container {
207
+ const result = (self.getContainer() as LoroMap).setContainer(
208
+ key,
209
+ container,
210
+ )
211
+ self.commitIfAuto()
212
+ return result
213
+ },
214
+ }
215
+ }
216
+ }