@loro-extended/change 0.8.1 → 0.9.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.
- package/README.md +78 -0
- package/dist/index.d.ts +190 -39
- package/dist/index.js +480 -295
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +277 -1
- package/src/discriminated-union-assignability.test.ts +45 -0
- package/src/discriminated-union-tojson.test.ts +128 -0
- package/src/index.ts +7 -0
- package/src/placeholder-proxy.test.ts +52 -0
- package/src/placeholder-proxy.ts +37 -0
- package/src/presence-interface.ts +52 -0
- package/src/shape.ts +44 -50
- package/src/typed-doc.ts +4 -4
- package/src/typed-presence.ts +96 -0
- package/src/{draft-nodes → typed-refs}/base.ts +4 -4
- package/src/{draft-nodes → typed-refs}/counter.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/counter.ts +9 -3
- package/src/{draft-nodes → typed-refs}/doc.ts +27 -13
- package/src/typed-refs/json-compatibility.test.ts +255 -0
- package/src/{draft-nodes → typed-refs}/list-base.ts +79 -30
- package/src/{draft-nodes → typed-refs}/list.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/list.ts +4 -4
- package/src/{draft-nodes → typed-refs}/map.ts +33 -22
- package/src/{draft-nodes → typed-refs}/movable-list.test.ts +1 -1
- package/src/{draft-nodes → typed-refs}/movable-list.ts +6 -6
- package/src/{draft-nodes → typed-refs}/proxy-handlers.ts +25 -26
- package/src/{draft-nodes → typed-refs}/record.test.ts +69 -0
- package/src/{draft-nodes → typed-refs}/record.ts +50 -21
- package/src/{draft-nodes → typed-refs}/text.ts +13 -3
- package/src/{draft-nodes → typed-refs}/tree.ts +6 -3
- package/src/{draft-nodes → typed-refs}/utils.ts +23 -27
- package/src/types.test.ts +97 -2
- package/src/types.ts +62 -5
- package/src/draft-nodes/counter.md +0 -31
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
1
|
+
import type { ListRef } from "./list.js"
|
|
2
|
+
import type { MovableListRef } from "./movable-list.js"
|
|
3
|
+
import type { RecordRef } from "./record.js"
|
|
4
4
|
|
|
5
|
-
export const recordProxyHandler: ProxyHandler<
|
|
5
|
+
export const recordProxyHandler: ProxyHandler<RecordRef<any>> = {
|
|
6
6
|
get: (target, prop) => {
|
|
7
7
|
if (typeof prop === "string" && !(prop in target)) {
|
|
8
8
|
return target.get(prop)
|
|
@@ -38,7 +38,7 @@ export const recordProxyHandler: ProxyHandler<RecordDraftNode<any>> = {
|
|
|
38
38
|
},
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export const listProxyHandler: ProxyHandler<
|
|
41
|
+
export const listProxyHandler: ProxyHandler<ListRef<any>> = {
|
|
42
42
|
get: (target, prop) => {
|
|
43
43
|
if (typeof prop === "string") {
|
|
44
44
|
const index = Number(prop)
|
|
@@ -62,26 +62,25 @@ export const listProxyHandler: ProxyHandler<ListDraftNode<any>> = {
|
|
|
62
62
|
},
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export const movableListProxyHandler: ProxyHandler<
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return target.get(index)
|
|
72
|
-
}
|
|
65
|
+
export const movableListProxyHandler: ProxyHandler<MovableListRef<any>> = {
|
|
66
|
+
get: (target, prop) => {
|
|
67
|
+
if (typeof prop === "string") {
|
|
68
|
+
const index = Number(prop)
|
|
69
|
+
if (!Number.isNaN(index)) {
|
|
70
|
+
return target.get(index)
|
|
73
71
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
}
|
|
73
|
+
return Reflect.get(target, prop)
|
|
74
|
+
},
|
|
75
|
+
set: (target, prop, value) => {
|
|
76
|
+
if (typeof prop === "string") {
|
|
77
|
+
const index = Number(prop)
|
|
78
|
+
if (!Number.isNaN(index)) {
|
|
79
|
+
// MovableList supports set directly
|
|
80
|
+
target.set(index, value)
|
|
81
|
+
return true
|
|
84
82
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
83
|
+
}
|
|
84
|
+
return Reflect.set(target, prop, value)
|
|
85
|
+
},
|
|
86
|
+
}
|
|
@@ -266,4 +266,73 @@ describe("Record Types", () => {
|
|
|
266
266
|
expect(doc.toJSON().histories["user1"]).toEqual(["c"])
|
|
267
267
|
})
|
|
268
268
|
})
|
|
269
|
+
|
|
270
|
+
describe("Readonly access to non-existent keys", () => {
|
|
271
|
+
it("should not throw 'placeholder required' when accessing nested map values in a record", () => {
|
|
272
|
+
// This schema mirrors a real-world scenario:
|
|
273
|
+
// preferences: Record<string, { showTip: boolean }>
|
|
274
|
+
const schema = Shape.doc({
|
|
275
|
+
preferences: Shape.record(
|
|
276
|
+
Shape.map({
|
|
277
|
+
showTip: Shape.plain.boolean(),
|
|
278
|
+
}),
|
|
279
|
+
),
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const doc = new TypedDoc(schema)
|
|
283
|
+
|
|
284
|
+
// First, set a value for a specific peer
|
|
285
|
+
doc.change(d => {
|
|
286
|
+
d.preferences.peer1 = { showTip: true }
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// This should work - accessing an existing key
|
|
290
|
+
expect(doc.value.preferences.peer1?.showTip).toBe(true)
|
|
291
|
+
|
|
292
|
+
// Accessing a non-existent key should NOT throw "placeholder required"
|
|
293
|
+
// It should return undefined so optional chaining works correctly
|
|
294
|
+
expect(() => {
|
|
295
|
+
const result = doc.value.preferences.nonexistent?.showTip
|
|
296
|
+
return result
|
|
297
|
+
}).not.toThrow()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("should return undefined for non-existent record keys in readonly mode", () => {
|
|
301
|
+
const schema = Shape.doc({
|
|
302
|
+
preferences: Shape.record(
|
|
303
|
+
Shape.map({
|
|
304
|
+
showTip: Shape.plain.boolean(),
|
|
305
|
+
}),
|
|
306
|
+
),
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const doc = new TypedDoc(schema)
|
|
310
|
+
|
|
311
|
+
// Access a key that doesn't exist - should return undefined
|
|
312
|
+
const prefs = doc.value.preferences.nonexistent
|
|
313
|
+
expect(prefs).toBeUndefined()
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it("should work with the exact user scenario pattern", () => {
|
|
317
|
+
// Exact reproduction of a user's schema and access pattern
|
|
318
|
+
const schema = Shape.doc({
|
|
319
|
+
preferences: Shape.record(
|
|
320
|
+
Shape.map({
|
|
321
|
+
showTip: Shape.plain.boolean(),
|
|
322
|
+
}),
|
|
323
|
+
),
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
const doc = new TypedDoc(schema)
|
|
327
|
+
const myPeerId = "some-peer-id"
|
|
328
|
+
|
|
329
|
+
// This is the exact code pattern from the user's app:
|
|
330
|
+
// doc.preferences[myPeerId]?.showTip !== false
|
|
331
|
+
expect(() => {
|
|
332
|
+
const showTip = doc.value.preferences[myPeerId]?.showTip
|
|
333
|
+
const result = showTip !== false
|
|
334
|
+
return result
|
|
335
|
+
}).not.toThrow()
|
|
336
|
+
})
|
|
337
|
+
})
|
|
269
338
|
})
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
LoroTree,
|
|
9
9
|
type Value,
|
|
10
10
|
} from "loro-crdt"
|
|
11
|
+
import { deriveShapePlaceholder } from "../derive-placeholder.js"
|
|
11
12
|
import type {
|
|
12
13
|
ContainerOrValueShape,
|
|
13
14
|
ContainerShape,
|
|
@@ -15,11 +16,8 @@ import type {
|
|
|
15
16
|
} from "../shape.js"
|
|
16
17
|
import type { Infer, InferDraftType } from "../types.js"
|
|
17
18
|
import { isContainerShape, isValueShape } from "../utils/type-guards.js"
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
assignPlainValueToDraftNode,
|
|
21
|
-
createContainerDraftNode,
|
|
22
|
-
} from "./utils.js"
|
|
19
|
+
import { TypedRef, type TypedRefParams } from "./base.js"
|
|
20
|
+
import { assignPlainValueToTypedRef, createContainerTypedRef } from "./utils.js"
|
|
23
21
|
|
|
24
22
|
const containerConstructor = {
|
|
25
23
|
counter: LoroCounter,
|
|
@@ -31,12 +29,12 @@ const containerConstructor = {
|
|
|
31
29
|
tree: LoroTree,
|
|
32
30
|
} as const
|
|
33
31
|
|
|
34
|
-
// Record
|
|
35
|
-
export class
|
|
32
|
+
// Record typed ref
|
|
33
|
+
export class RecordRef<
|
|
36
34
|
NestedShape extends ContainerOrValueShape,
|
|
37
|
-
> extends
|
|
35
|
+
> extends TypedRef<any> {
|
|
38
36
|
[key: string]: Infer<NestedShape> | any
|
|
39
|
-
private nodeCache = new Map<string,
|
|
37
|
+
private nodeCache = new Map<string, TypedRef<ContainerShape> | Value>()
|
|
40
38
|
|
|
41
39
|
protected get shape(): RecordContainerShape<NestedShape> {
|
|
42
40
|
return super.shape as RecordContainerShape<NestedShape>
|
|
@@ -48,8 +46,8 @@ export class RecordDraftNode<
|
|
|
48
46
|
|
|
49
47
|
absorbPlainValues() {
|
|
50
48
|
for (const [key, node] of this.nodeCache.entries()) {
|
|
51
|
-
if (node instanceof
|
|
52
|
-
// Contains a
|
|
49
|
+
if (node instanceof TypedRef) {
|
|
50
|
+
// Contains a TypedRef, not a plain Value: keep recursing
|
|
53
51
|
node.absorbPlainValues()
|
|
54
52
|
continue
|
|
55
53
|
}
|
|
@@ -59,11 +57,19 @@ export class RecordDraftNode<
|
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
getTypedRefParams<S extends ContainerShape>(
|
|
63
61
|
key: string,
|
|
64
62
|
shape: S,
|
|
65
|
-
):
|
|
66
|
-
|
|
63
|
+
): TypedRefParams<ContainerShape> {
|
|
64
|
+
// First try to get placeholder from the Record's placeholder (if it has an entry for this key)
|
|
65
|
+
let placeholder = (this.placeholder as any)?.[key]
|
|
66
|
+
|
|
67
|
+
// If no placeholder exists for this key, derive one from the schema's shape
|
|
68
|
+
// This is critical for Records where the placeholder is always {} but nested
|
|
69
|
+
// containers need valid placeholders to fall back to for missing values
|
|
70
|
+
if (placeholder === undefined) {
|
|
71
|
+
placeholder = deriveShapePlaceholder(shape)
|
|
72
|
+
}
|
|
67
73
|
|
|
68
74
|
const LoroContainer = containerConstructor[shape._type]
|
|
69
75
|
|
|
@@ -77,12 +83,22 @@ export class RecordDraftNode<
|
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
getOrCreateNode(key: string): any {
|
|
86
|
+
// For readonly mode with container shapes, check if the key exists first
|
|
87
|
+
// This allows optional chaining (?.) to work correctly for non-existent keys
|
|
88
|
+
// Similar to how ListRefBase.getMutableItem() handles non-existent indices
|
|
89
|
+
if (this.readonly && isContainerShape(this.shape.shape)) {
|
|
90
|
+
const existing = this.container.get(key)
|
|
91
|
+
if (existing === undefined) {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
80
96
|
let node = this.nodeCache.get(key)
|
|
81
97
|
if (!node) {
|
|
82
98
|
const shape = this.shape.shape
|
|
83
99
|
if (isContainerShape(shape)) {
|
|
84
|
-
node =
|
|
85
|
-
this.
|
|
100
|
+
node = createContainerTypedRef(
|
|
101
|
+
this.getTypedRefParams(key, shape as ContainerShape),
|
|
86
102
|
)
|
|
87
103
|
// Cache container nodes
|
|
88
104
|
this.nodeCache.set(key, node)
|
|
@@ -127,7 +143,7 @@ export class RecordDraftNode<
|
|
|
127
143
|
}
|
|
128
144
|
|
|
129
145
|
set(key: string, value: any): void {
|
|
130
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
146
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
131
147
|
if (isValueShape(this.shape.shape)) {
|
|
132
148
|
this.container.set(key, value)
|
|
133
149
|
this.nodeCache.set(key, value)
|
|
@@ -137,24 +153,24 @@ export class RecordDraftNode<
|
|
|
137
153
|
if (value && typeof value === "object") {
|
|
138
154
|
const node = this.getOrCreateNode(key)
|
|
139
155
|
|
|
140
|
-
if (
|
|
156
|
+
if (assignPlainValueToTypedRef(node, value)) {
|
|
141
157
|
return
|
|
142
158
|
}
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
throw new Error(
|
|
146
|
-
"Cannot set container directly, modify the
|
|
162
|
+
"Cannot set container directly, modify the typed ref instead",
|
|
147
163
|
)
|
|
148
164
|
}
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
setContainer<C extends Container>(key: string, container: C): C {
|
|
152
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
168
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
153
169
|
return this.container.setContainer(key, container)
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
delete(key: string): void {
|
|
157
|
-
if (this.readonly) throw new Error("Cannot modify readonly
|
|
173
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
158
174
|
this.container.delete(key)
|
|
159
175
|
this.nodeCache.delete(key)
|
|
160
176
|
}
|
|
@@ -174,4 +190,17 @@ export class RecordDraftNode<
|
|
|
174
190
|
get size(): number {
|
|
175
191
|
return this.container.size
|
|
176
192
|
}
|
|
193
|
+
|
|
194
|
+
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
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
177
206
|
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import type { TextContainerShape } from "../shape.js"
|
|
2
|
-
import {
|
|
2
|
+
import { TypedRef } from "./base.js"
|
|
3
3
|
|
|
4
|
-
// Text
|
|
5
|
-
export class
|
|
4
|
+
// Text typed ref
|
|
5
|
+
export class TextRef extends TypedRef<TextContainerShape> {
|
|
6
6
|
absorbPlainValues() {
|
|
7
7
|
// no plain values contained within
|
|
8
8
|
}
|
|
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
13
|
this.container.insert(index, content)
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
delete(index: number, len: number): void {
|
|
17
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
16
18
|
this.container.delete(index, len)
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -20,15 +22,22 @@ export class TextDraftNode extends DraftNode<TextContainerShape> {
|
|
|
20
22
|
return this.container.toString()
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
toJSON(): string {
|
|
26
|
+
return this.toString()
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
update(text: string): void {
|
|
30
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
24
31
|
this.container.update(text)
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
mark(range: { start: number; end: number }, key: string, value: any): void {
|
|
35
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
28
36
|
this.container.mark(range, key, value)
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
unmark(range: { start: number; end: number }, key: string): void {
|
|
40
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
32
41
|
this.container.unmark(range, key)
|
|
33
42
|
}
|
|
34
43
|
|
|
@@ -37,6 +46,7 @@ export class TextDraftNode extends DraftNode<TextContainerShape> {
|
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
applyDelta(delta: any[]): void {
|
|
49
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
40
50
|
this.container.applyDelta(delta)
|
|
41
51
|
}
|
|
42
52
|
|
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import type { TreeContainerShape } from "../shape.js"
|
|
2
|
-
import {
|
|
2
|
+
import { TypedRef } from "./base.js"
|
|
3
3
|
|
|
4
|
-
// Tree
|
|
5
|
-
export class
|
|
4
|
+
// Tree typed ref
|
|
5
|
+
export class TreeRef<T extends TreeContainerShape> extends TypedRef<T> {
|
|
6
6
|
absorbPlainValues() {
|
|
7
7
|
// TODO(duane): implement for trees
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
createNode(parent?: any, index?: number): any {
|
|
11
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
11
12
|
return this.container.createNode(parent, index)
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
move(target: any, parent?: any, index?: number): void {
|
|
16
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
15
17
|
this.container.move(target, parent, index)
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
delete(target: any): void {
|
|
21
|
+
if (this.readonly) throw new Error("Cannot modify readonly ref")
|
|
19
22
|
this.container.delete(target)
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -8,57 +8,53 @@ import type {
|
|
|
8
8
|
TextContainerShape,
|
|
9
9
|
TreeContainerShape,
|
|
10
10
|
} from "../shape.js"
|
|
11
|
-
import type {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import type { TypedRef, TypedRefParams } from "./base.js"
|
|
12
|
+
import { CounterRef } from "./counter.js"
|
|
13
|
+
import { ListRef } from "./list.js"
|
|
14
|
+
import { MapRef } from "./map.js"
|
|
15
|
+
import { MovableListRef } from "./movable-list.js"
|
|
16
16
|
import {
|
|
17
17
|
listProxyHandler,
|
|
18
18
|
movableListProxyHandler,
|
|
19
19
|
recordProxyHandler,
|
|
20
20
|
} from "./proxy-handlers.js"
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
21
|
+
import { RecordRef } from "./record.js"
|
|
22
|
+
import { TextRef } from "./text.js"
|
|
23
|
+
import { TreeRef } from "./tree.js"
|
|
24
24
|
|
|
25
25
|
// Generic catch-all overload
|
|
26
|
-
export function
|
|
27
|
-
params:
|
|
28
|
-
):
|
|
26
|
+
export function createContainerTypedRef<T extends ContainerShape>(
|
|
27
|
+
params: TypedRefParams<T>,
|
|
28
|
+
): TypedRef<T>
|
|
29
29
|
|
|
30
30
|
// Implementation
|
|
31
|
-
export function
|
|
32
|
-
params:
|
|
33
|
-
):
|
|
31
|
+
export function createContainerTypedRef(
|
|
32
|
+
params: TypedRefParams<ContainerShape>,
|
|
33
|
+
): TypedRef<ContainerShape> {
|
|
34
34
|
switch (params.shape._type) {
|
|
35
35
|
case "counter":
|
|
36
|
-
return new
|
|
37
|
-
params as DraftNodeParams<CounterContainerShape>,
|
|
38
|
-
)
|
|
36
|
+
return new CounterRef(params as TypedRefParams<CounterContainerShape>)
|
|
39
37
|
case "list":
|
|
40
38
|
return new Proxy(
|
|
41
|
-
new
|
|
39
|
+
new ListRef(params as TypedRefParams<ListContainerShape>),
|
|
42
40
|
listProxyHandler,
|
|
43
41
|
)
|
|
44
42
|
case "map":
|
|
45
|
-
return new
|
|
43
|
+
return new MapRef(params as TypedRefParams<MapContainerShape>)
|
|
46
44
|
case "movableList":
|
|
47
45
|
return new Proxy(
|
|
48
|
-
new
|
|
49
|
-
params as DraftNodeParams<MovableListContainerShape>,
|
|
50
|
-
),
|
|
46
|
+
new MovableListRef(params as TypedRefParams<MovableListContainerShape>),
|
|
51
47
|
movableListProxyHandler,
|
|
52
48
|
)
|
|
53
49
|
case "record":
|
|
54
50
|
return new Proxy(
|
|
55
|
-
new
|
|
51
|
+
new RecordRef(params as TypedRefParams<RecordContainerShape>),
|
|
56
52
|
recordProxyHandler,
|
|
57
53
|
)
|
|
58
54
|
case "text":
|
|
59
|
-
return new
|
|
55
|
+
return new TextRef(params as TypedRefParams<TextContainerShape>)
|
|
60
56
|
case "tree":
|
|
61
|
-
return new
|
|
57
|
+
return new TreeRef(params as TypedRefParams<TreeContainerShape>)
|
|
62
58
|
default:
|
|
63
59
|
throw new Error(
|
|
64
60
|
`Unknown container type: ${(params.shape as ContainerShape)._type}`,
|
|
@@ -66,8 +62,8 @@ export function createContainerDraftNode(
|
|
|
66
62
|
}
|
|
67
63
|
}
|
|
68
64
|
|
|
69
|
-
export function
|
|
70
|
-
node:
|
|
65
|
+
export function assignPlainValueToTypedRef(
|
|
66
|
+
node: TypedRef<any>,
|
|
71
67
|
value: any,
|
|
72
68
|
): boolean {
|
|
73
69
|
const shapeType = (node as any).shape._type
|
package/src/types.test.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, expectTypeOf, it } from "vitest"
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
2
2
|
import type { ContainerShape, ValueShape } from "./shape.js"
|
|
3
3
|
import { Shape } from "./shape.js"
|
|
4
|
-
import
|
|
4
|
+
import { createTypedDoc } from "./typed-doc.js"
|
|
5
|
+
import type { DeepReadonly, Infer } from "./types.js"
|
|
5
6
|
|
|
6
7
|
describe("Infer type helper", () => {
|
|
7
8
|
it("infers DocShape plain type", () => {
|
|
@@ -186,3 +187,97 @@ describe("Infer type helper", () => {
|
|
|
186
187
|
expectTypeOf<Result>().toEqualTypeOf<Expected>()
|
|
187
188
|
})
|
|
188
189
|
})
|
|
190
|
+
|
|
191
|
+
describe("DeepReadonly type helper", () => {
|
|
192
|
+
it("Object.values returns clean types without toJSON function in union", () => {
|
|
193
|
+
const ParticipantSchema = Shape.plain.object({
|
|
194
|
+
id: Shape.plain.string(),
|
|
195
|
+
name: Shape.plain.string(),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const GroupSessionSchema = Shape.doc({
|
|
199
|
+
participants: Shape.record(ParticipantSchema),
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const doc = createTypedDoc(GroupSessionSchema)
|
|
203
|
+
|
|
204
|
+
doc.change((root: any) => {
|
|
205
|
+
root.participants.set("p1", { id: "1", name: "Alice" })
|
|
206
|
+
root.participants.set("p2", { id: "2", name: "Bob" })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const participants = doc.value.participants
|
|
210
|
+
|
|
211
|
+
// Object.values should return clean types
|
|
212
|
+
const values = Object.values(participants)
|
|
213
|
+
|
|
214
|
+
type Participant = Infer<typeof ParticipantSchema>
|
|
215
|
+
|
|
216
|
+
// FIXED: Object.values now returns clean DeepReadonly<Participant>[]
|
|
217
|
+
// Previously it returned: (DeepReadonly<Participant> | (() => Record<...>))[]
|
|
218
|
+
expectTypeOf(values).toEqualTypeOf<DeepReadonly<Participant>[]>()
|
|
219
|
+
|
|
220
|
+
// Runtime check
|
|
221
|
+
expect(values).toHaveLength(2)
|
|
222
|
+
expect(values.map(p => p.name).sort()).toEqual(["Alice", "Bob"])
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("toJSON is still callable on Records", () => {
|
|
226
|
+
const ParticipantSchema = Shape.plain.object({
|
|
227
|
+
id: Shape.plain.string(),
|
|
228
|
+
name: Shape.plain.string(),
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
const GroupSessionSchema = Shape.doc({
|
|
232
|
+
participants: Shape.record(ParticipantSchema),
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
const doc = createTypedDoc(GroupSessionSchema)
|
|
236
|
+
|
|
237
|
+
doc.change((root: any) => {
|
|
238
|
+
root.participants.set("p1", { id: "1", name: "Alice" })
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const participants = doc.value.participants
|
|
242
|
+
|
|
243
|
+
// toJSON should be callable
|
|
244
|
+
const json = participants.toJSON()
|
|
245
|
+
|
|
246
|
+
// Type check: toJSON returns the plain Record type
|
|
247
|
+
expectTypeOf(json).toEqualTypeOf<
|
|
248
|
+
Record<string, { id: string; name: string }>
|
|
249
|
+
>()
|
|
250
|
+
|
|
251
|
+
// Runtime check
|
|
252
|
+
expect(json).toEqual({ p1: { id: "1", name: "Alice" } })
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("toJSON is still callable on Maps", () => {
|
|
256
|
+
const MetaSchema = Shape.map({
|
|
257
|
+
title: Shape.plain.string(),
|
|
258
|
+
count: Shape.plain.number(),
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const DocSchema = Shape.doc({
|
|
262
|
+
meta: MetaSchema,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const doc = createTypedDoc(DocSchema)
|
|
266
|
+
|
|
267
|
+
doc.change((root: any) => {
|
|
268
|
+
root.meta.title = "Test"
|
|
269
|
+
root.meta.count = 42
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const meta = doc.value.meta
|
|
273
|
+
|
|
274
|
+
// toJSON should be callable
|
|
275
|
+
const json = meta.toJSON()
|
|
276
|
+
|
|
277
|
+
// Type check
|
|
278
|
+
expectTypeOf(json).toEqualTypeOf<{ title: string; count: number }>()
|
|
279
|
+
|
|
280
|
+
// Runtime check
|
|
281
|
+
expect(json).toEqual({ title: "Test", count: 42 })
|
|
282
|
+
})
|
|
283
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -36,7 +36,16 @@ import type { ContainerShape, DocShape, Shape } from "./shape.js"
|
|
|
36
36
|
*/
|
|
37
37
|
export type Infer<T> = T extends Shape<infer P, any, any> ? P : never
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Infers the mutable type from any Shape.
|
|
41
|
+
* This is the type used within change() callbacks for mutation.
|
|
42
|
+
*/
|
|
43
|
+
export type InferMutableType<T> = T extends Shape<any, infer M, any> ? M : never
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @deprecated Use InferMutableType<T> instead
|
|
47
|
+
*/
|
|
48
|
+
export type InferDraftType<T> = InferMutableType<T>
|
|
40
49
|
|
|
41
50
|
/**
|
|
42
51
|
* Extracts the valid placeholder type from a shape.
|
|
@@ -48,10 +57,58 @@ export type InferPlaceholderType<T> = T extends Shape<any, any, infer P>
|
|
|
48
57
|
? P
|
|
49
58
|
: never
|
|
50
59
|
|
|
51
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Mutable type for use within change() callbacks.
|
|
62
|
+
* This is the type-safe wrapper around CRDT containers that allows mutation.
|
|
63
|
+
*/
|
|
64
|
+
export type Mutable<T extends DocShape<Record<string, ContainerShape>>> =
|
|
65
|
+
InferMutableType<T>
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @deprecated Use Mutable<T> instead
|
|
69
|
+
*/
|
|
52
70
|
export type Draft<T extends DocShape<Record<string, ContainerShape>>> =
|
|
53
|
-
|
|
71
|
+
Mutable<T>
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Interface for objects that have a toJSON method.
|
|
75
|
+
* This is separate from the data type to avoid polluting Object.values().
|
|
76
|
+
*/
|
|
77
|
+
export interface HasToJSON<T> {
|
|
78
|
+
toJSON(): T
|
|
57
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Deep readonly wrapper for plain objects (no index signature).
|
|
83
|
+
* Includes toJSON() method.
|
|
84
|
+
*/
|
|
85
|
+
export type DeepReadonlyObject<T extends object> = {
|
|
86
|
+
readonly [P in keyof T]: DeepReadonly<T[P]>
|
|
87
|
+
} & HasToJSON<T>
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Deep readonly wrapper for Record types (with string index signature).
|
|
91
|
+
* The toJSON() method is available but NOT part of the index signature,
|
|
92
|
+
* so Object.values() returns clean types.
|
|
93
|
+
*/
|
|
94
|
+
export type DeepReadonlyRecord<T> = {
|
|
95
|
+
readonly [K in keyof T]: DeepReadonly<T[K]>
|
|
96
|
+
} & HasToJSON<Record<string, T[keyof T]>>
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Deep readonly wrapper that makes all properties readonly recursively
|
|
100
|
+
* and adds a toJSON() method for JSON serialization.
|
|
101
|
+
*
|
|
102
|
+
* For arrays: Returns ReadonlyArray with toJSON()
|
|
103
|
+
* For objects with string index signature (Records): toJSON() is available
|
|
104
|
+
* but doesn't pollute Object.values() type inference
|
|
105
|
+
* For plain objects: Returns readonly properties with toJSON()
|
|
106
|
+
* For primitives: Returns as-is
|
|
107
|
+
*/
|
|
108
|
+
export type DeepReadonly<T> = T extends any[]
|
|
109
|
+
? ReadonlyArray<DeepReadonly<T[number]>> & HasToJSON<T>
|
|
110
|
+
: T extends object
|
|
111
|
+
? string extends keyof T
|
|
112
|
+
? DeepReadonlyRecord<T>
|
|
113
|
+
: DeepReadonlyObject<T>
|
|
114
|
+
: T
|