@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,156 @@
1
+ import {
2
+ type Container,
3
+ LoroCounter,
4
+ LoroList,
5
+ LoroMap,
6
+ LoroMovableList,
7
+ LoroText,
8
+ LoroTree,
9
+ type Value,
10
+ } from "loro-crdt"
11
+ import type {
12
+ ContainerOrValueShape,
13
+ ContainerShape,
14
+ MapContainerShape,
15
+ ValueShape,
16
+ } from "../shape.js"
17
+ import { isContainerShape, isValueShape } from "../utils/type-guards.js"
18
+ import { DraftNode, type DraftNodeParams } from "./base.js"
19
+ import { createContainerDraftNode } 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
30
+
31
+ // Map draft node
32
+ export class MapDraftNode<
33
+ NestedShapes extends Record<string, ContainerOrValueShape>,
34
+ > extends DraftNode<any> {
35
+ private propertyCache = new Map<string, DraftNode<ContainerShape> | Value>()
36
+
37
+ constructor(params: DraftNodeParams<MapContainerShape<NestedShapes>>) {
38
+ super(params)
39
+ this.createLazyProperties()
40
+ }
41
+
42
+ protected get shape(): MapContainerShape<NestedShapes> {
43
+ return super.shape as MapContainerShape<NestedShapes>
44
+ }
45
+
46
+ protected get container(): LoroMap {
47
+ return super.container as LoroMap
48
+ }
49
+
50
+ absorbPlainValues() {
51
+ for (const [key, node] of this.propertyCache.entries()) {
52
+ if (node instanceof DraftNode) {
53
+ // Contains a DraftNode, not a plain Value: keep recursing
54
+ node.absorbPlainValues()
55
+ continue
56
+ }
57
+
58
+ // Plain value!
59
+ this.container.set(key, node)
60
+ }
61
+ }
62
+
63
+ getDraftNodeParams<S extends ContainerShape>(
64
+ key: string,
65
+ shape: S,
66
+ ): DraftNodeParams<ContainerShape> {
67
+ const emptyState = (this.emptyState as any)?.[key]
68
+
69
+ const LoroContainer = containerConstructor[shape._type]
70
+
71
+ return {
72
+ shape,
73
+ emptyState,
74
+ getContainer: () =>
75
+ this.container.getOrCreateContainer(key, new (LoroContainer as any)()),
76
+ }
77
+ }
78
+
79
+ getOrCreateNode<Shape extends ContainerShape | ValueShape>(
80
+ key: string,
81
+ shape: Shape,
82
+ ): Shape extends ContainerShape ? DraftNode<Shape> : Value {
83
+ let node = this.propertyCache.get(key)
84
+ if (!node) {
85
+ if (isContainerShape(shape)) {
86
+ node = createContainerDraftNode(this.getDraftNodeParams(key, shape))
87
+ } else {
88
+ // For value shapes, first try to get the value from the container
89
+ const containerValue = this.container.get(key)
90
+ if (containerValue !== undefined) {
91
+ node = containerValue as Value
92
+ } else {
93
+ // Only fall back to empty state if the container doesn't have the value
94
+ const emptyState = (this.emptyState as any)?.[key]
95
+ if (!emptyState) {
96
+ throw new Error("empty state required")
97
+ }
98
+ node = emptyState as Value
99
+ }
100
+ }
101
+ if (!node) throw new Error("no container made")
102
+ this.propertyCache.set(key, node)
103
+ }
104
+
105
+ return node as Shape extends ContainerShape ? DraftNode<Shape> : Value
106
+ }
107
+
108
+ private createLazyProperties(): void {
109
+ for (const key in this.shape.shapes) {
110
+ const shape = this.shape.shapes[key]
111
+ Object.defineProperty(this, key, {
112
+ get: () => this.getOrCreateNode(key, shape),
113
+ set: isValueShape(shape)
114
+ ? value => {
115
+ // console.log("set value", value)
116
+ this.container.set(key, value)
117
+ }
118
+ : undefined,
119
+ })
120
+ }
121
+ }
122
+
123
+ // TOOD(duane): return correct type here
124
+ get(key: string): any {
125
+ return this.container.get(key)
126
+ }
127
+
128
+ set(key: string, value: Value): void {
129
+ this.container.set(key, value)
130
+ }
131
+
132
+ setContainer<C extends Container>(key: string, container: C): C {
133
+ return this.container.setContainer(key, container)
134
+ }
135
+
136
+ delete(key: string): void {
137
+ this.container.delete(key)
138
+ }
139
+
140
+ has(key: string): boolean {
141
+ // LoroMap doesn't have a has method, so we check if get returns undefined
142
+ return this.container.get(key) !== undefined
143
+ }
144
+
145
+ keys(): string[] {
146
+ return this.container.keys()
147
+ }
148
+
149
+ values(): any[] {
150
+ return this.container.values()
151
+ }
152
+
153
+ get size(): number {
154
+ return this.container.size
155
+ }
156
+ }
@@ -0,0 +1,26 @@
1
+ import type { Container, LoroMovableList } from "loro-crdt"
2
+ import type { ContainerOrValueShape } from "../shape.js"
3
+ import { ListDraftNodeBase } from "./list-base.js"
4
+
5
+ // Movable list draft node
6
+ export class MovableListDraftNode<
7
+ NestedShape extends ContainerOrValueShape,
8
+ Item = NestedShape["_plain"],
9
+ > extends ListDraftNodeBase<NestedShape> {
10
+ protected get container(): LoroMovableList {
11
+ return super.container as LoroMovableList
12
+ }
13
+
14
+ protected absorbValueAtIndex(index: number, value: any): void {
15
+ // LoroMovableList has set method
16
+ this.container.set(index, value)
17
+ }
18
+
19
+ move(from: number, to: number): void {
20
+ this.container.move(from, to)
21
+ }
22
+
23
+ set(index: number, item: Exclude<Item, Container>) {
24
+ return this.container.set(index, item)
25
+ }
26
+ }
@@ -0,0 +1,215 @@
1
+ import {
2
+ type Container,
3
+ LoroCounter,
4
+ LoroList,
5
+ LoroMap,
6
+ LoroMovableList,
7
+ LoroText,
8
+ LoroTree,
9
+ type Value,
10
+ } from "loro-crdt"
11
+ import type {
12
+ ContainerOrValueShape,
13
+ ContainerShape,
14
+ RecordContainerShape,
15
+ } from "../shape.js"
16
+ import type { InferDraftType } from "../types.js"
17
+ import { isContainerShape, isValueShape } from "../utils/type-guards.js"
18
+ import { DraftNode, type DraftNodeParams } from "./base.js"
19
+ import { createContainerDraftNode } 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
30
+
31
+ // Record draft node
32
+ export class RecordDraftNode<
33
+ NestedShape extends ContainerOrValueShape,
34
+ > extends DraftNode<any> {
35
+ private nodeCache = new Map<string, DraftNode<ContainerShape> | Value>()
36
+
37
+ constructor(params: DraftNodeParams<RecordContainerShape<NestedShape>>) {
38
+ super(params)
39
+ // We don't need to create lazy properties because keys are dynamic
40
+ // But we could use a Proxy if we wanted property access syntax like record.key
41
+ // However, for now let's stick to get/set methods or maybe Proxy for better DX?
42
+ // The requirement says "records with uniform specific key type and value".
43
+ // Usually records are accessed via keys.
44
+ // If we want `draft.record.key`, we need a Proxy.
45
+ // biome-ignore lint/correctness/noConstructorReturn: Proxy return is intentional
46
+ return new Proxy(this, {
47
+ get: (target, prop) => {
48
+ if (typeof prop === "string" && !(prop in target)) {
49
+ return target.get(prop)
50
+ }
51
+ return Reflect.get(target, prop)
52
+ },
53
+ set: (target, prop, value) => {
54
+ if (typeof prop === "string" && !(prop in target)) {
55
+ target.set(prop, value)
56
+ return true
57
+ }
58
+ return Reflect.set(target, prop, value)
59
+ },
60
+ deleteProperty: (target, prop) => {
61
+ if (typeof prop === "string" && !(prop in target)) {
62
+ target.delete(prop)
63
+ return true
64
+ }
65
+ return Reflect.deleteProperty(target, prop)
66
+ },
67
+ ownKeys: target => {
68
+ return target.keys()
69
+ },
70
+ getOwnPropertyDescriptor: (target, prop) => {
71
+ if (typeof prop === "string" && target.has(prop)) {
72
+ return {
73
+ configurable: true,
74
+ enumerable: true,
75
+ value: target.get(prop),
76
+ }
77
+ }
78
+ return Reflect.getOwnPropertyDescriptor(target, prop)
79
+ },
80
+ })
81
+ }
82
+
83
+ protected get shape(): RecordContainerShape<NestedShape> {
84
+ return super.shape as RecordContainerShape<NestedShape>
85
+ }
86
+
87
+ protected get container(): LoroMap {
88
+ return super.container as LoroMap
89
+ }
90
+
91
+ absorbPlainValues() {
92
+ for (const [key, node] of this.nodeCache.entries()) {
93
+ if (node instanceof DraftNode) {
94
+ // Contains a DraftNode, not a plain Value: keep recursing
95
+ node.absorbPlainValues()
96
+ continue
97
+ }
98
+
99
+ // Plain value!
100
+ this.container.set(key, node)
101
+ }
102
+ }
103
+
104
+ getDraftNodeParams<S extends ContainerShape>(
105
+ key: string,
106
+ shape: S,
107
+ ): DraftNodeParams<ContainerShape> {
108
+ const emptyState = (this.emptyState as any)?.[key]
109
+
110
+ const LoroContainer = containerConstructor[shape._type]
111
+
112
+ return {
113
+ shape,
114
+ emptyState,
115
+ getContainer: () =>
116
+ this.container.getOrCreateContainer(key, new (LoroContainer as any)()),
117
+ }
118
+ }
119
+
120
+ getOrCreateNode(key: string): InferDraftType<NestedShape> {
121
+ let node = this.nodeCache.get(key)
122
+ if (!node) {
123
+ const shape = this.shape.shape
124
+ if (isContainerShape(shape)) {
125
+ node = createContainerDraftNode(
126
+ this.getDraftNodeParams(key, shape as ContainerShape),
127
+ )
128
+ } else {
129
+ // For value shapes, first try to get the value from the container
130
+ const containerValue = this.container.get(key)
131
+ if (containerValue !== undefined) {
132
+ node = containerValue as Value
133
+ } else {
134
+ // Only fall back to empty state if the container doesn't have the value
135
+ const emptyState = (this.emptyState as any)?.[key]
136
+ // For records, empty state might not have the key, which is fine?
137
+ // But if we are accessing it, maybe we expect it to exist or be created?
138
+ // If it's a value type, we can't really "create" it without a value.
139
+ // So if it's undefined in container and empty state, we return undefined?
140
+ // But the return type expects Value.
141
+ // Let's check MapDraftNode.
142
+ // MapDraftNode throws "empty state required" if not found.
143
+ // But for Record, keys are dynamic.
144
+ if (emptyState === undefined) {
145
+ // If it's a value type and not in container or empty state,
146
+ // we should probably return undefined if the type allows it,
147
+ // or maybe the default value for that type?
148
+ // But we don't have a default value generator for shapes.
149
+ // Actually Shape.plain.* factories have _plain and _draft which are defaults.
150
+ node = (shape as any)._plain
151
+ } else {
152
+ node = emptyState as Value
153
+ }
154
+ }
155
+ }
156
+ if (node !== undefined) {
157
+ this.nodeCache.set(key, node)
158
+ }
159
+ }
160
+
161
+ return node as any
162
+ }
163
+
164
+ get(key: string): InferDraftType<NestedShape> {
165
+ return this.getOrCreateNode(key)
166
+ }
167
+
168
+ set(key: string, value: any): void {
169
+ if (isValueShape(this.shape.shape)) {
170
+ this.container.set(key, value)
171
+ // Update cache if needed?
172
+ // MapDraftNode updates container directly for values.
173
+ // But we also cache values in nodeCache for consistency?
174
+ // MapDraftNode doesn't cache values in propertyCache if they are set via setter?
175
+ // Actually MapDraftNode setter:
176
+ // set: isValueShape(shape) ? value => this.container.set(key, value) : undefined
177
+ // It doesn't update propertyCache.
178
+ // But getOrCreateNode checks propertyCache first.
179
+ // So if we set it, we should probably update propertyCache or clear it for that key.
180
+ this.nodeCache.set(key, value)
181
+ } else {
182
+ // For containers, we can't set them directly usually.
183
+ // But if the user passes a plain object that matches the shape, maybe we should convert it?
184
+ // But typically we modify the draft node.
185
+ throw new Error(
186
+ "Cannot set container directly, modify the draft node instead",
187
+ )
188
+ }
189
+ }
190
+
191
+ setContainer<C extends Container>(key: string, container: C): C {
192
+ return this.container.setContainer(key, container)
193
+ }
194
+
195
+ delete(key: string): void {
196
+ this.container.delete(key)
197
+ this.nodeCache.delete(key)
198
+ }
199
+
200
+ has(key: string): boolean {
201
+ return this.container.get(key) !== undefined
202
+ }
203
+
204
+ keys(): string[] {
205
+ return this.container.keys()
206
+ }
207
+
208
+ values(): any[] {
209
+ return this.container.values()
210
+ }
211
+
212
+ get size(): number {
213
+ return this.container.size
214
+ }
215
+ }
@@ -0,0 +1,48 @@
1
+ import type { TextContainerShape } from "../shape.js"
2
+ import { DraftNode } from "./base.js"
3
+
4
+ // Text draft node
5
+ export class TextDraftNode extends DraftNode<TextContainerShape> {
6
+ absorbPlainValues() {
7
+ // no plain values contained within
8
+ }
9
+
10
+ // Text methods
11
+ insert(index: number, content: string): void {
12
+ // TODO(duane): condition can be removed when https://github.com/loro-dev/loro/issues/872 is addressed
13
+ if (content.length === 0) return
14
+ this.container.insert(index, content)
15
+ }
16
+
17
+ delete(index: number, len: number): void {
18
+ this.container.delete(index, len)
19
+ }
20
+
21
+ toString(): string {
22
+ return this.container.toString()
23
+ }
24
+
25
+ update(text: string): void {
26
+ this.container.update(text)
27
+ }
28
+
29
+ mark(range: { start: number; end: number }, key: string, value: any): void {
30
+ this.container.mark(range, key, value)
31
+ }
32
+
33
+ unmark(range: { start: number; end: number }, key: string): void {
34
+ this.container.unmark(range, key)
35
+ }
36
+
37
+ toDelta(): any[] {
38
+ return this.container.toDelta()
39
+ }
40
+
41
+ applyDelta(delta: any[]): void {
42
+ this.container.applyDelta(delta)
43
+ }
44
+
45
+ get length(): number {
46
+ return this.container.length
47
+ }
48
+ }
@@ -0,0 +1,31 @@
1
+ import type { TreeContainerShape } from "../shape.js"
2
+ import { DraftNode } from "./base.js"
3
+
4
+ // Tree draft node
5
+ export class TreeDraftNode<T extends TreeContainerShape> extends DraftNode<T> {
6
+ absorbPlainValues() {
7
+ // TODO(duane): implement for trees
8
+ }
9
+
10
+ createNode(parent?: any, index?: number): any {
11
+ return this.container.createNode(parent, index)
12
+ }
13
+
14
+ move(target: any, parent?: any, index?: number): void {
15
+ this.container.move(target, parent, index)
16
+ }
17
+
18
+ delete(target: any): void {
19
+ this.container.delete(target)
20
+ }
21
+
22
+ has(target: any): boolean {
23
+ return this.container.has(target)
24
+ }
25
+
26
+ getNodeByID(id: any): any {
27
+ return this.container.getNodeByID
28
+ ? this.container.getNodeByID(id)
29
+ : undefined
30
+ }
31
+ }
@@ -0,0 +1,55 @@
1
+ import type {
2
+ ContainerShape,
3
+ CounterContainerShape,
4
+ ListContainerShape,
5
+ MapContainerShape,
6
+ MovableListContainerShape,
7
+ RecordContainerShape,
8
+ TextContainerShape,
9
+ TreeContainerShape,
10
+ } from "../shape.js"
11
+ import type { DraftNode, DraftNodeParams } from "./base.js"
12
+ import { CounterDraftNode } from "./counter.js"
13
+ import { ListDraftNode } from "./list.js"
14
+ import { MapDraftNode } from "./map.js"
15
+ import { MovableListDraftNode } from "./movable-list.js"
16
+ import { RecordDraftNode } from "./record.js"
17
+ import { TextDraftNode } from "./text.js"
18
+ import { TreeDraftNode } from "./tree.js"
19
+
20
+ // Generic catch-all overload
21
+ export function createContainerDraftNode<T extends ContainerShape>(
22
+ params: DraftNodeParams<T>,
23
+ ): DraftNode<T>
24
+
25
+ // Implementation
26
+ export function createContainerDraftNode(
27
+ params: DraftNodeParams<ContainerShape>,
28
+ ): DraftNode<ContainerShape> {
29
+ switch (params.shape._type) {
30
+ case "counter":
31
+ return new CounterDraftNode(
32
+ params as DraftNodeParams<CounterContainerShape>,
33
+ )
34
+ case "list":
35
+ return new ListDraftNode(params as DraftNodeParams<ListContainerShape>)
36
+ case "map":
37
+ return new MapDraftNode(params as DraftNodeParams<MapContainerShape>)
38
+ case "movableList":
39
+ return new MovableListDraftNode(
40
+ params as DraftNodeParams<MovableListContainerShape>,
41
+ )
42
+ case "record":
43
+ return new RecordDraftNode(
44
+ params as DraftNodeParams<RecordContainerShape>,
45
+ )
46
+ case "text":
47
+ return new TextDraftNode(params as DraftNodeParams<TextContainerShape>)
48
+ case "tree":
49
+ return new TreeDraftNode(params as DraftNodeParams<TreeContainerShape>)
50
+ default:
51
+ throw new Error(
52
+ `Unknown container type: ${(params.shape as ContainerShape)._type}`,
53
+ )
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Main API exports
2
+ export { createTypedDoc, TypedDoc } from "./change.js"
3
+ export { mergeValue, overlayEmptyState } from "./overlay.js"
4
+ export type {
5
+ ArrayValueShape,
6
+ ContainerOrValueShape,
7
+ ContainerShape,
8
+ ContainerType as RootContainerType,
9
+ // Container shapes
10
+ CounterContainerShape,
11
+ // Schema node types
12
+ DocShape,
13
+ ListContainerShape,
14
+ MapContainerShape,
15
+ MovableListContainerShape,
16
+ RecordContainerShape,
17
+ RecordValueShape,
18
+ TextContainerShape,
19
+ TreeContainerShape,
20
+ // Value shapes
21
+ ValueShape,
22
+ // ...
23
+ } from "./shape.js"
24
+ // Schema and type exports
25
+ export { Shape } from "./shape.js"
26
+ export type {
27
+ Draft,
28
+ InferDraftType,
29
+ // Type inference
30
+ InferPlainType,
31
+ } from "./types.js"
32
+ // Utility exports
33
+ export { validateEmptyState } from "./validation.js"