@loro-extended/change 0.9.1 → 1.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.
- package/README.md +179 -69
- package/dist/index.d.ts +361 -169
- package/dist/index.js +516 -235
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/change.test.ts +180 -175
- package/src/conversion.test.ts +19 -19
- package/src/conversion.ts +7 -7
- package/src/derive-placeholder.test.ts +14 -14
- package/src/derive-placeholder.ts +3 -3
- package/src/discriminated-union-assignability.test.ts +7 -7
- package/src/discriminated-union-tojson.test.ts +13 -24
- package/src/discriminated-union.test.ts +9 -8
- package/src/equality.test.ts +10 -2
- package/src/functional-helpers.test.ts +149 -0
- package/src/functional-helpers.ts +61 -0
- package/src/grand-unified-api.test.ts +423 -0
- package/src/index.ts +8 -6
- package/src/json-patch.test.ts +64 -56
- package/src/overlay-recursion.test.ts +23 -22
- package/src/overlay.ts +9 -9
- package/src/readonly.test.ts +27 -26
- package/src/shape.ts +103 -21
- package/src/typed-doc.ts +227 -58
- package/src/typed-refs/base.ts +23 -1
- package/src/typed-refs/counter.test.ts +44 -13
- package/src/typed-refs/counter.ts +40 -3
- package/src/typed-refs/doc.ts +12 -6
- package/src/typed-refs/json-compatibility.test.ts +37 -32
- package/src/typed-refs/list-base.ts +26 -22
- package/src/typed-refs/list.test.ts +4 -3
- package/src/typed-refs/movable-list.test.ts +3 -2
- package/src/typed-refs/movable-list.ts +4 -1
- package/src/typed-refs/proxy-handlers.ts +14 -1
- package/src/typed-refs/record.test.ts +107 -42
- package/src/typed-refs/record.ts +37 -19
- package/src/typed-refs/{map.ts → struct.ts} +31 -16
- package/src/typed-refs/text.ts +42 -1
- package/src/typed-refs/utils.ts +28 -6
- package/src/types.test.ts +34 -39
- package/src/types.ts +5 -40
- package/src/utils/type-guards.ts +11 -6
- package/src/validation.ts +10 -10
package/src/typed-refs/text.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import type { LoroText } from "loro-crdt"
|
|
1
2
|
import type { TextContainerShape } from "../shape.js"
|
|
2
3
|
import { TypedRef } from "./base.js"
|
|
3
4
|
|
|
4
5
|
// Text typed ref
|
|
5
6
|
export class TextRef extends TypedRef<TextContainerShape> {
|
|
7
|
+
// Track if we've materialized the container (made any changes)
|
|
8
|
+
private _materialized = false
|
|
9
|
+
|
|
10
|
+
protected get container(): LoroText {
|
|
11
|
+
return super.container as LoroText
|
|
12
|
+
}
|
|
13
|
+
|
|
6
14
|
absorbPlainValues() {
|
|
7
15
|
// no plain values contained within
|
|
8
16
|
}
|
|
@@ -10,35 +18,66 @@ export class TextRef extends TypedRef<TextContainerShape> {
|
|
|
10
18
|
// Text methods
|
|
11
19
|
insert(index: number, content: string): void {
|
|
12
20
|
this.assertMutable()
|
|
21
|
+
this._materialized = true
|
|
13
22
|
this.container.insert(index, content)
|
|
23
|
+
this.commitIfAuto()
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
delete(index: number, len: number): void {
|
|
17
27
|
this.assertMutable()
|
|
28
|
+
this._materialized = true
|
|
18
29
|
this.container.delete(index, len)
|
|
30
|
+
this.commitIfAuto()
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Returns the text content.
|
|
35
|
+
* If the text hasn't been materialized (no operations performed),
|
|
36
|
+
* returns the placeholder value if available.
|
|
37
|
+
*/
|
|
21
38
|
toString(): string {
|
|
22
|
-
|
|
39
|
+
const containerValue = this.container.toString()
|
|
40
|
+
if (containerValue !== "" || this._materialized) {
|
|
41
|
+
return containerValue
|
|
42
|
+
}
|
|
43
|
+
// Return placeholder if available and container is at default state
|
|
44
|
+
if (this.placeholder !== undefined) {
|
|
45
|
+
return this.placeholder as string
|
|
46
|
+
}
|
|
47
|
+
return containerValue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
valueOf(): string {
|
|
51
|
+
return this.toString()
|
|
23
52
|
}
|
|
24
53
|
|
|
25
54
|
toJSON(): string {
|
|
26
55
|
return this.toString()
|
|
27
56
|
}
|
|
28
57
|
|
|
58
|
+
[Symbol.toPrimitive](_hint: string): string {
|
|
59
|
+
return this.toString()
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
update(text: string): void {
|
|
30
63
|
this.assertMutable()
|
|
64
|
+
this._materialized = true
|
|
31
65
|
this.container.update(text)
|
|
66
|
+
this.commitIfAuto()
|
|
32
67
|
}
|
|
33
68
|
|
|
34
69
|
mark(range: { start: number; end: number }, key: string, value: any): void {
|
|
35
70
|
this.assertMutable()
|
|
71
|
+
this._materialized = true
|
|
36
72
|
this.container.mark(range, key, value)
|
|
73
|
+
this.commitIfAuto()
|
|
37
74
|
}
|
|
38
75
|
|
|
39
76
|
unmark(range: { start: number; end: number }, key: string): void {
|
|
40
77
|
this.assertMutable()
|
|
78
|
+
this._materialized = true
|
|
41
79
|
this.container.unmark(range, key)
|
|
80
|
+
this.commitIfAuto()
|
|
42
81
|
}
|
|
43
82
|
|
|
44
83
|
toDelta(): any[] {
|
|
@@ -47,7 +86,9 @@ export class TextRef extends TypedRef<TextContainerShape> {
|
|
|
47
86
|
|
|
48
87
|
applyDelta(delta: any[]): void {
|
|
49
88
|
this.assertMutable()
|
|
89
|
+
this._materialized = true
|
|
50
90
|
this.container.applyDelta(delta)
|
|
91
|
+
this.commitIfAuto()
|
|
51
92
|
}
|
|
52
93
|
|
|
53
94
|
get length(): number {
|
package/src/typed-refs/utils.ts
CHANGED
|
@@ -11,16 +11,15 @@ import type {
|
|
|
11
11
|
ContainerShape,
|
|
12
12
|
CounterContainerShape,
|
|
13
13
|
ListContainerShape,
|
|
14
|
-
MapContainerShape,
|
|
15
14
|
MovableListContainerShape,
|
|
16
15
|
RecordContainerShape,
|
|
16
|
+
StructContainerShape,
|
|
17
17
|
TextContainerShape,
|
|
18
18
|
TreeContainerShape,
|
|
19
19
|
} from "../shape.js"
|
|
20
20
|
import { TypedRef, type TypedRefParams } from "./base.js"
|
|
21
21
|
import { CounterRef } from "./counter.js"
|
|
22
22
|
import { ListRef } from "./list.js"
|
|
23
|
-
import { MapRef } from "./map.js"
|
|
24
23
|
import { MovableListRef } from "./movable-list.js"
|
|
25
24
|
import {
|
|
26
25
|
listProxyHandler,
|
|
@@ -28,6 +27,7 @@ import {
|
|
|
28
27
|
recordProxyHandler,
|
|
29
28
|
} from "./proxy-handlers.js"
|
|
30
29
|
import { RecordRef } from "./record.js"
|
|
30
|
+
import { StructRef } from "./struct.js"
|
|
31
31
|
import { TextRef } from "./text.js"
|
|
32
32
|
import { TreeRef } from "./tree.js"
|
|
33
33
|
|
|
@@ -38,9 +38,9 @@ import { TreeRef } from "./tree.js"
|
|
|
38
38
|
export const containerConstructor = {
|
|
39
39
|
counter: LoroCounter,
|
|
40
40
|
list: LoroList,
|
|
41
|
-
map: LoroMap,
|
|
42
41
|
movableList: LoroMovableList,
|
|
43
42
|
record: LoroMap, // Records use LoroMap as their underlying container
|
|
43
|
+
struct: LoroMap, // Structs use LoroMap as their underlying container
|
|
44
44
|
text: LoroText,
|
|
45
45
|
tree: LoroTree,
|
|
46
46
|
} as const
|
|
@@ -124,8 +124,8 @@ export function createContainerTypedRef(
|
|
|
124
124
|
new ListRef(params as TypedRefParams<ListContainerShape>),
|
|
125
125
|
listProxyHandler,
|
|
126
126
|
)
|
|
127
|
-
case "
|
|
128
|
-
return new
|
|
127
|
+
case "struct":
|
|
128
|
+
return new StructRef(params as TypedRefParams<StructContainerShape>)
|
|
129
129
|
case "movableList":
|
|
130
130
|
return new Proxy(
|
|
131
131
|
new MovableListRef(params as TypedRefParams<MovableListContainerShape>),
|
|
@@ -153,7 +153,7 @@ export function assignPlainValueToTypedRef(
|
|
|
153
153
|
): boolean {
|
|
154
154
|
const shapeType = (ref as any).shape._type
|
|
155
155
|
|
|
156
|
-
if (shapeType === "
|
|
156
|
+
if (shapeType === "struct" || shapeType === "record") {
|
|
157
157
|
for (const k in value) {
|
|
158
158
|
;(ref as any)[k] = value[k]
|
|
159
159
|
}
|
|
@@ -173,5 +173,27 @@ export function assignPlainValueToTypedRef(
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
if (shapeType === "text") {
|
|
177
|
+
if (typeof value === "string") {
|
|
178
|
+
;(ref as any).update(value)
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (shapeType === "counter") {
|
|
185
|
+
if (typeof value === "number") {
|
|
186
|
+
const currentValue = (ref as any).value
|
|
187
|
+
const diff = value - currentValue
|
|
188
|
+
if (diff > 0) {
|
|
189
|
+
;(ref as any).increment(diff)
|
|
190
|
+
} else if (diff < 0) {
|
|
191
|
+
;(ref as any).decrement(-diff)
|
|
192
|
+
}
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
|
|
176
198
|
return false
|
|
177
199
|
}
|
package/src/types.test.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
2
|
+
import { change } from "./functional-helpers.js"
|
|
2
3
|
import type { ContainerShape, ValueShape } from "./shape.js"
|
|
3
4
|
import { Shape } from "./shape.js"
|
|
4
5
|
import { createTypedDoc } from "./typed-doc.js"
|
|
5
|
-
import type {
|
|
6
|
+
import type { Infer } from "./types.js"
|
|
6
7
|
|
|
7
8
|
describe("Infer type helper", () => {
|
|
8
9
|
it("infers DocShape plain type", () => {
|
|
@@ -23,7 +24,7 @@ describe("Infer type helper", () => {
|
|
|
23
24
|
})
|
|
24
25
|
|
|
25
26
|
it("infers ValueShape plain type (object)", () => {
|
|
26
|
-
const schema = Shape.plain.
|
|
27
|
+
const schema = Shape.plain.struct({
|
|
27
28
|
name: Shape.plain.string(),
|
|
28
29
|
age: Shape.plain.number(),
|
|
29
30
|
})
|
|
@@ -35,10 +36,10 @@ describe("Infer type helper", () => {
|
|
|
35
36
|
it("infers nested shapes", () => {
|
|
36
37
|
const schema = Shape.doc({
|
|
37
38
|
users: Shape.list(
|
|
38
|
-
Shape.
|
|
39
|
+
Shape.struct({
|
|
39
40
|
id: Shape.plain.string(),
|
|
40
41
|
profile: Shape.record(
|
|
41
|
-
Shape.plain.
|
|
42
|
+
Shape.plain.struct({
|
|
42
43
|
bio: Shape.plain.string(),
|
|
43
44
|
}),
|
|
44
45
|
),
|
|
@@ -57,18 +58,18 @@ describe("Infer type helper", () => {
|
|
|
57
58
|
|
|
58
59
|
it("infers discriminated union plain type", () => {
|
|
59
60
|
const SessionStatusSchema = Shape.plain.discriminatedUnion("status", {
|
|
60
|
-
not_started: Shape.plain.
|
|
61
|
+
not_started: Shape.plain.struct({
|
|
61
62
|
status: Shape.plain.string("not_started"),
|
|
62
63
|
}),
|
|
63
|
-
lobby: Shape.plain.
|
|
64
|
+
lobby: Shape.plain.struct({
|
|
64
65
|
status: Shape.plain.string("lobby"),
|
|
65
66
|
lobbyPhase: Shape.plain.string("preparing", "typing"),
|
|
66
67
|
}),
|
|
67
|
-
active: Shape.plain.
|
|
68
|
+
active: Shape.plain.struct({
|
|
68
69
|
status: Shape.plain.string("active"),
|
|
69
70
|
mode: Shape.plain.string("solo", "group"),
|
|
70
71
|
}),
|
|
71
|
-
paused: Shape.plain.
|
|
72
|
+
paused: Shape.plain.struct({
|
|
72
73
|
status: Shape.plain.string("paused"),
|
|
73
74
|
previousStatus: Shape.plain.string("lobby", "active"),
|
|
74
75
|
previousMode: Shape.plain.string("solo", "group"),
|
|
@@ -78,7 +79,7 @@ describe("Infer type helper", () => {
|
|
|
78
79
|
"assignment_empty",
|
|
79
80
|
),
|
|
80
81
|
}),
|
|
81
|
-
ended: Shape.plain.
|
|
82
|
+
ended: Shape.plain.struct({
|
|
82
83
|
status: Shape.plain.string<"ended">("ended"),
|
|
83
84
|
}),
|
|
84
85
|
})
|
|
@@ -103,16 +104,16 @@ describe("Infer type helper", () => {
|
|
|
103
104
|
|
|
104
105
|
it("infers discriminated union inside a map container", () => {
|
|
105
106
|
const SessionStatusSchema = Shape.plain.discriminatedUnion("status", {
|
|
106
|
-
not_started: Shape.plain.
|
|
107
|
+
not_started: Shape.plain.struct({
|
|
107
108
|
status: Shape.plain.string("not_started"),
|
|
108
109
|
}),
|
|
109
|
-
active: Shape.plain.
|
|
110
|
+
active: Shape.plain.struct({
|
|
110
111
|
status: Shape.plain.string("active"),
|
|
111
112
|
mode: Shape.plain.string("solo", "group"),
|
|
112
113
|
}),
|
|
113
114
|
})
|
|
114
115
|
|
|
115
|
-
const SessionMetadataSchema = Shape.
|
|
116
|
+
const SessionMetadataSchema = Shape.struct({
|
|
116
117
|
sessionStartedAt: Shape.plain.number(),
|
|
117
118
|
sessionStatus: SessionStatusSchema,
|
|
118
119
|
})
|
|
@@ -131,16 +132,16 @@ describe("Infer type helper", () => {
|
|
|
131
132
|
|
|
132
133
|
it("infers discriminated union inside a doc", () => {
|
|
133
134
|
const SessionStatusSchema = Shape.plain.discriminatedUnion("status", {
|
|
134
|
-
not_started: Shape.plain.
|
|
135
|
+
not_started: Shape.plain.struct({
|
|
135
136
|
status: Shape.plain.string("not_started"),
|
|
136
137
|
}),
|
|
137
|
-
active: Shape.plain.
|
|
138
|
+
active: Shape.plain.struct({
|
|
138
139
|
status: Shape.plain.string("active"),
|
|
139
140
|
}),
|
|
140
141
|
})
|
|
141
142
|
|
|
142
143
|
const DocSchema = Shape.doc({
|
|
143
|
-
metadata: Shape.
|
|
144
|
+
metadata: Shape.struct({
|
|
144
145
|
sessionStartedAt: Shape.plain.number(),
|
|
145
146
|
sessionStatus: SessionStatusSchema,
|
|
146
147
|
}),
|
|
@@ -162,11 +163,11 @@ describe("Infer type helper", () => {
|
|
|
162
163
|
// This test verifies the fix for usePresence type inference
|
|
163
164
|
// The issue was that DiscriminatedUnionValueShape<any, any> in the ValueShape union
|
|
164
165
|
// caused type information to be lost when inferring through generic constraints
|
|
165
|
-
const ClientPresenceShape = Shape.plain.
|
|
166
|
+
const ClientPresenceShape = Shape.plain.struct({
|
|
166
167
|
type: Shape.plain.string("client"),
|
|
167
168
|
name: Shape.plain.string(),
|
|
168
169
|
})
|
|
169
|
-
const ServerPresenceShape = Shape.plain.
|
|
170
|
+
const ServerPresenceShape = Shape.plain.struct({
|
|
170
171
|
type: Shape.plain.string("server"),
|
|
171
172
|
tick: Shape.plain.number(),
|
|
172
173
|
})
|
|
@@ -188,9 +189,9 @@ describe("Infer type helper", () => {
|
|
|
188
189
|
})
|
|
189
190
|
})
|
|
190
191
|
|
|
191
|
-
describe("
|
|
192
|
-
it("Object.values returns
|
|
193
|
-
const ParticipantSchema = Shape.plain.
|
|
192
|
+
describe("Mutable type helper", () => {
|
|
193
|
+
it("Object.values returns values from the record", () => {
|
|
194
|
+
const ParticipantSchema = Shape.plain.struct({
|
|
194
195
|
id: Shape.plain.string(),
|
|
195
196
|
name: Shape.plain.string(),
|
|
196
197
|
})
|
|
@@ -201,29 +202,23 @@ describe("DeepReadonly type helper", () => {
|
|
|
201
202
|
|
|
202
203
|
const doc = createTypedDoc(GroupSessionSchema)
|
|
203
204
|
|
|
204
|
-
|
|
205
|
+
change(doc, (root: any) => {
|
|
205
206
|
root.participants.set("p1", { id: "1", name: "Alice" })
|
|
206
207
|
root.participants.set("p2", { id: "2", name: "Bob" })
|
|
207
208
|
})
|
|
208
209
|
|
|
209
|
-
const participants = doc.
|
|
210
|
+
const participants = doc.participants
|
|
210
211
|
|
|
211
|
-
// Object.values
|
|
212
|
+
// Object.values returns the values from the record
|
|
212
213
|
const values = Object.values(participants)
|
|
213
214
|
|
|
214
|
-
|
|
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
|
|
215
|
+
// Runtime check - values are the plain objects
|
|
221
216
|
expect(values).toHaveLength(2)
|
|
222
|
-
expect(values.map(p => p.name).sort()).toEqual(["Alice", "Bob"])
|
|
217
|
+
expect(values.map((p: any) => p.name).sort()).toEqual(["Alice", "Bob"])
|
|
223
218
|
})
|
|
224
219
|
|
|
225
|
-
it("toJSON is
|
|
226
|
-
const ParticipantSchema = Shape.plain.
|
|
220
|
+
it("toJSON is callable on Records", () => {
|
|
221
|
+
const ParticipantSchema = Shape.plain.struct({
|
|
227
222
|
id: Shape.plain.string(),
|
|
228
223
|
name: Shape.plain.string(),
|
|
229
224
|
})
|
|
@@ -234,11 +229,11 @@ describe("DeepReadonly type helper", () => {
|
|
|
234
229
|
|
|
235
230
|
const doc = createTypedDoc(GroupSessionSchema)
|
|
236
231
|
|
|
237
|
-
|
|
232
|
+
change(doc, (root: any) => {
|
|
238
233
|
root.participants.set("p1", { id: "1", name: "Alice" })
|
|
239
234
|
})
|
|
240
235
|
|
|
241
|
-
const participants = doc.
|
|
236
|
+
const participants = doc.participants
|
|
242
237
|
|
|
243
238
|
// toJSON should be callable
|
|
244
239
|
const json = participants.toJSON()
|
|
@@ -252,8 +247,8 @@ describe("DeepReadonly type helper", () => {
|
|
|
252
247
|
expect(json).toEqual({ p1: { id: "1", name: "Alice" } })
|
|
253
248
|
})
|
|
254
249
|
|
|
255
|
-
it("toJSON is
|
|
256
|
-
const MetaSchema = Shape.
|
|
250
|
+
it("toJSON is callable on Maps", () => {
|
|
251
|
+
const MetaSchema = Shape.struct({
|
|
257
252
|
title: Shape.plain.string(),
|
|
258
253
|
count: Shape.plain.number(),
|
|
259
254
|
})
|
|
@@ -264,12 +259,12 @@ describe("DeepReadonly type helper", () => {
|
|
|
264
259
|
|
|
265
260
|
const doc = createTypedDoc(DocSchema)
|
|
266
261
|
|
|
267
|
-
|
|
262
|
+
change(doc, (root: any) => {
|
|
268
263
|
root.meta.title = "Test"
|
|
269
264
|
root.meta.count = 42
|
|
270
265
|
})
|
|
271
266
|
|
|
272
|
-
const meta = doc.
|
|
267
|
+
const meta = doc.meta
|
|
273
268
|
|
|
274
269
|
// toJSON should be callable
|
|
275
270
|
const json = meta.toJSON()
|
package/src/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type { ContainerShape, DocShape, Shape } from "./shape.js"
|
|
|
14
14
|
* @example
|
|
15
15
|
* ```typescript
|
|
16
16
|
* const ChatSchema = Shape.doc({
|
|
17
|
-
* messages: Shape.list(Shape.
|
|
17
|
+
* messages: Shape.list(Shape.struct({
|
|
18
18
|
* id: Shape.plain.string(),
|
|
19
19
|
* content: Shape.text(),
|
|
20
20
|
* })),
|
|
@@ -24,9 +24,9 @@ import type { ContainerShape, DocShape, Shape } from "./shape.js"
|
|
|
24
24
|
* type ChatDoc = Infer<typeof ChatSchema>
|
|
25
25
|
* // Result: { messages: { id: string; content: string }[] }
|
|
26
26
|
*
|
|
27
|
-
* const PresenceSchema = Shape.plain.
|
|
27
|
+
* const PresenceSchema = Shape.plain.struct({
|
|
28
28
|
* name: Shape.plain.string(),
|
|
29
|
-
* cursor: Shape.plain.
|
|
29
|
+
* cursor: Shape.plain.struct({ x: Shape.plain.number(), y: Shape.plain.number() }),
|
|
30
30
|
* })
|
|
31
31
|
*
|
|
32
32
|
* // Extract the presence type
|
|
@@ -58,14 +58,14 @@ export type InferPlaceholderType<T> = T extends Shape<any, any, infer P>
|
|
|
58
58
|
: never
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Mutable type for use within change() callbacks.
|
|
61
|
+
* Mutable type for use within change() callbacks and direct mutations on doc.value.
|
|
62
62
|
* This is the type-safe wrapper around CRDT containers that allows mutation.
|
|
63
63
|
*/
|
|
64
64
|
export type Mutable<T extends DocShape<Record<string, ContainerShape>>> =
|
|
65
65
|
InferMutableType<T>
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
* @deprecated Use Mutable<T> instead
|
|
68
|
+
* @deprecated Use Mutable<T> instead. Draft is an alias kept for backwards compatibility.
|
|
69
69
|
*/
|
|
70
70
|
export type Draft<T extends DocShape<Record<string, ContainerShape>>> =
|
|
71
71
|
Mutable<T>
|
|
@@ -77,38 +77,3 @@ export type Draft<T extends DocShape<Record<string, ContainerShape>>> =
|
|
|
77
77
|
export interface HasToJSON<T> {
|
|
78
78
|
toJSON(): T
|
|
79
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
|
package/src/utils/type-guards.ts
CHANGED
|
@@ -14,9 +14,9 @@ import type {
|
|
|
14
14
|
ContainerShape,
|
|
15
15
|
CounterContainerShape,
|
|
16
16
|
ListContainerShape,
|
|
17
|
-
MapContainerShape,
|
|
18
17
|
MovableListContainerShape,
|
|
19
18
|
RecordContainerShape,
|
|
19
|
+
StructContainerShape,
|
|
20
20
|
TextContainerShape,
|
|
21
21
|
TreeContainerShape,
|
|
22
22
|
ValueShape,
|
|
@@ -146,14 +146,19 @@ export function isMovableListShape(
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
|
-
* Type guard to check if a schema is for
|
|
149
|
+
* Type guard to check if a schema is for StructDraftNode
|
|
150
150
|
*/
|
|
151
|
-
export function
|
|
151
|
+
export function isStructShape(
|
|
152
152
|
schema: ContainerOrValueShape,
|
|
153
|
-
): schema is
|
|
154
|
-
return schema && typeof schema === "object" && schema._type === "
|
|
153
|
+
): schema is StructContainerShape {
|
|
154
|
+
return schema && typeof schema === "object" && schema._type === "struct"
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* @deprecated Use isStructShape instead. isMapShape is an alias for backward compatibility.
|
|
159
|
+
*/
|
|
160
|
+
export const isMapShape = isStructShape
|
|
161
|
+
|
|
157
162
|
/**
|
|
158
163
|
* Type guard to check if a schema is for RecordDraftNode
|
|
159
164
|
*/
|
|
@@ -193,7 +198,7 @@ export function isValueShape(
|
|
|
193
198
|
"null",
|
|
194
199
|
"undefined",
|
|
195
200
|
"uint8array",
|
|
196
|
-
"
|
|
201
|
+
"struct",
|
|
197
202
|
"record",
|
|
198
203
|
"array",
|
|
199
204
|
"union",
|
package/src/validation.ts
CHANGED
|
@@ -4,12 +4,12 @@ import type {
|
|
|
4
4
|
DiscriminatedUnionValueShape,
|
|
5
5
|
DocShape,
|
|
6
6
|
ListContainerShape,
|
|
7
|
-
MapContainerShape,
|
|
8
7
|
MovableListContainerShape,
|
|
9
|
-
ObjectValueShape,
|
|
10
8
|
RecordContainerShape,
|
|
11
9
|
RecordValueShape,
|
|
12
10
|
StringValueShape,
|
|
11
|
+
StructContainerShape,
|
|
12
|
+
StructValueShape,
|
|
13
13
|
UnionValueShape,
|
|
14
14
|
ValueShape,
|
|
15
15
|
} from "./shape.js"
|
|
@@ -60,17 +60,17 @@ export function validateValue(
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
if (schema._type === "
|
|
63
|
+
if (schema._type === "struct") {
|
|
64
64
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
65
65
|
throw new Error(
|
|
66
66
|
`Expected object at path ${currentPath}, got ${typeof value}`,
|
|
67
67
|
)
|
|
68
68
|
}
|
|
69
|
-
const
|
|
69
|
+
const structSchema = schema as StructContainerShape
|
|
70
70
|
const result: Record<string, unknown> = {}
|
|
71
71
|
|
|
72
|
-
// Validate each property in the
|
|
73
|
-
for (const [key, nestedSchema] of Object.entries(
|
|
72
|
+
// Validate each property in the struct shape
|
|
73
|
+
for (const [key, nestedSchema] of Object.entries(structSchema.shapes)) {
|
|
74
74
|
const nestedPath = `${currentPath}.${key}`
|
|
75
75
|
const nestedValue = (value as Record<string, unknown>)[key]
|
|
76
76
|
result[key] = validateValue(nestedValue, nestedSchema, nestedPath)
|
|
@@ -165,17 +165,17 @@ export function validateValue(
|
|
|
165
165
|
}
|
|
166
166
|
return value
|
|
167
167
|
|
|
168
|
-
case "
|
|
168
|
+
case "struct": {
|
|
169
169
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
170
170
|
throw new Error(
|
|
171
171
|
`Expected object at path ${currentPath}, got ${typeof value}`,
|
|
172
172
|
)
|
|
173
173
|
}
|
|
174
|
-
const
|
|
174
|
+
const structSchema = valueSchema as StructValueShape
|
|
175
175
|
const result: Record<string, unknown> = {}
|
|
176
176
|
|
|
177
|
-
// Validate each property in the
|
|
178
|
-
for (const [key, nestedSchema] of Object.entries(
|
|
177
|
+
// Validate each property in the struct shape
|
|
178
|
+
for (const [key, nestedSchema] of Object.entries(structSchema.shape)) {
|
|
179
179
|
const nestedPath = `${currentPath}.${key}`
|
|
180
180
|
const nestedValue = (value as Record<string, unknown>)[key]
|
|
181
181
|
result[key] = validateValue(nestedValue, nestedSchema, nestedPath)
|