@kyneta/yjs-schema 1.1.0 → 1.3.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 +3 -3
- package/dist/index.d.ts +97 -225
- package/dist/index.js +281 -316
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/bind-constraints.test.ts +325 -0
- package/src/__tests__/bind-yjs.test.ts +79 -70
- package/src/__tests__/create.test.ts +88 -65
- package/src/__tests__/reader.test.ts +38 -72
- package/src/__tests__/record-text-spike.test.ts +47 -46
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +62 -58
- package/src/__tests__/version.test.ts +75 -0
- package/src/bind-yjs.ts +40 -41
- package/src/change-mapping.ts +50 -54
- package/src/index.ts +29 -44
- package/src/native-map.ts +37 -0
- package/src/populate.ts +49 -82
- package/src/substrate.ts +68 -8
- package/src/version.ts +54 -52
- package/src/yjs-resolve.ts +0 -10
- package/src/create.ts +0 -177
- package/src/sync.ts +0 -107
- package/src/yjs-escape.ts +0 -84
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// bind-constraints — compile-time and runtime tests for yjs.bind() caps enforcement.
|
|
2
|
+
//
|
|
3
|
+
// Verifies that `yjs.bind()` rejects schemas containing capabilities
|
|
4
|
+
// that Yjs doesn't support (counter, movable, tree, set) at COMPILE TIME
|
|
5
|
+
// via the `RestrictCaps` / `AllowedCaps` mechanism, while accepting
|
|
6
|
+
// capabilities it does support (text) and plain schemas.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type BoundSchema,
|
|
10
|
+
type ExtractCaps,
|
|
11
|
+
json,
|
|
12
|
+
Schema,
|
|
13
|
+
} from "@kyneta/schema"
|
|
14
|
+
import { describe, expect, expectTypeOf, it } from "vitest"
|
|
15
|
+
import { yjs } from "../bind-yjs.js"
|
|
16
|
+
|
|
17
|
+
// ===========================================================================
|
|
18
|
+
// §1 — Compile-time acceptance: schemas that yjs.bind() SHOULD accept
|
|
19
|
+
// ===========================================================================
|
|
20
|
+
|
|
21
|
+
describe("yjs.bind() accepts Yjs-compatible schemas", () => {
|
|
22
|
+
it("plain schema (no caps)", () => {
|
|
23
|
+
const schema = Schema.struct({
|
|
24
|
+
name: Schema.string(),
|
|
25
|
+
count: Schema.number(),
|
|
26
|
+
active: Schema.boolean(),
|
|
27
|
+
})
|
|
28
|
+
const bound = yjs.bind(schema)
|
|
29
|
+
expect(bound).toBeDefined()
|
|
30
|
+
expect(bound.schema).toBe(schema)
|
|
31
|
+
expectTypeOf(bound).toMatchTypeOf<BoundSchema<typeof schema>>()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("schema with text", () => {
|
|
35
|
+
const schema = Schema.struct({
|
|
36
|
+
title: Schema.text(),
|
|
37
|
+
})
|
|
38
|
+
const bound = yjs.bind(schema)
|
|
39
|
+
expect(bound).toBeDefined()
|
|
40
|
+
expect(bound.schema).toBe(schema)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("schema with text + plain scalars", () => {
|
|
44
|
+
const schema = Schema.struct({
|
|
45
|
+
title: Schema.text(),
|
|
46
|
+
count: Schema.number(),
|
|
47
|
+
active: Schema.boolean(),
|
|
48
|
+
tags: Schema.list(Schema.string()),
|
|
49
|
+
})
|
|
50
|
+
const bound = yjs.bind(schema)
|
|
51
|
+
expect(bound).toBeDefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("deeply nested text is accepted", () => {
|
|
55
|
+
const schema = Schema.struct({
|
|
56
|
+
channels: Schema.list(
|
|
57
|
+
Schema.struct({
|
|
58
|
+
meta: Schema.record(
|
|
59
|
+
Schema.struct({
|
|
60
|
+
description: Schema.text(),
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
const bound = yjs.bind(schema)
|
|
67
|
+
expect(bound).toBeDefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("optional text field alongside plain scalars is accepted", () => {
|
|
71
|
+
const schema = Schema.struct({
|
|
72
|
+
title: Schema.text(),
|
|
73
|
+
draft: Schema.text(),
|
|
74
|
+
count: Schema.number(),
|
|
75
|
+
})
|
|
76
|
+
const bound = yjs.bind(schema)
|
|
77
|
+
expect(bound).toBeDefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("preserves full schema type through bind()", () => {
|
|
81
|
+
const schema = Schema.struct({
|
|
82
|
+
title: Schema.text(),
|
|
83
|
+
items: Schema.list(
|
|
84
|
+
Schema.struct({ name: Schema.string(), done: Schema.boolean() }),
|
|
85
|
+
),
|
|
86
|
+
})
|
|
87
|
+
const bound = yjs.bind(schema)
|
|
88
|
+
expectTypeOf(bound.schema).toEqualTypeOf(schema)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("json merge boundary is accepted", () => {
|
|
92
|
+
const schema = Schema.struct({
|
|
93
|
+
title: Schema.text(),
|
|
94
|
+
metadata: Schema.struct.json({
|
|
95
|
+
version: Schema.number(),
|
|
96
|
+
tags: Schema.list(Schema.string()),
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
99
|
+
const bound = yjs.bind(schema)
|
|
100
|
+
expect(bound).toBeDefined()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// ===========================================================================
|
|
105
|
+
// §2 — Compile-time rejection: schemas that yjs.bind() SHOULD reject
|
|
106
|
+
// ===========================================================================
|
|
107
|
+
|
|
108
|
+
describe("yjs.bind() rejects schemas with unsupported caps", () => {
|
|
109
|
+
it("rejects counter", () => {
|
|
110
|
+
const schema = Schema.struct({
|
|
111
|
+
count: Schema.counter(),
|
|
112
|
+
})
|
|
113
|
+
// @ts-expect-error — counter is not in YjsCaps
|
|
114
|
+
yjs.bind(schema)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("rejects movableList", () => {
|
|
118
|
+
const schema = Schema.struct({
|
|
119
|
+
items: Schema.movableList(Schema.string()),
|
|
120
|
+
})
|
|
121
|
+
// @ts-expect-error — movable is not in YjsCaps
|
|
122
|
+
yjs.bind(schema)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("rejects tree", () => {
|
|
126
|
+
const schema = Schema.struct({
|
|
127
|
+
hierarchy: Schema.tree(Schema.struct({ label: Schema.string() })),
|
|
128
|
+
})
|
|
129
|
+
// @ts-expect-error — tree is not in YjsCaps
|
|
130
|
+
yjs.bind(schema)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("rejects set", () => {
|
|
134
|
+
const schema = Schema.struct({
|
|
135
|
+
tags: Schema.set(Schema.string()),
|
|
136
|
+
})
|
|
137
|
+
// @ts-expect-error — set is not in YjsCaps
|
|
138
|
+
yjs.bind(schema)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("rejects deeply nested counter", () => {
|
|
142
|
+
const schema = Schema.struct({
|
|
143
|
+
items: Schema.list(
|
|
144
|
+
Schema.struct({
|
|
145
|
+
meta: Schema.record(Schema.struct({ hits: Schema.counter() })),
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
})
|
|
149
|
+
// @ts-expect-error — counter is deeply nested but still caught
|
|
150
|
+
yjs.bind(schema)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("rejects mix of supported and unsupported (text + counter)", () => {
|
|
154
|
+
const schema = Schema.struct({
|
|
155
|
+
title: Schema.text(),
|
|
156
|
+
views: Schema.counter(),
|
|
157
|
+
})
|
|
158
|
+
// @ts-expect-error — counter is not in YjsCaps
|
|
159
|
+
yjs.bind(schema)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("rejects counter inside list", () => {
|
|
163
|
+
const schema = Schema.struct({
|
|
164
|
+
scores: Schema.list(Schema.struct({ value: Schema.counter() })),
|
|
165
|
+
})
|
|
166
|
+
// @ts-expect-error — counter nested inside list struct
|
|
167
|
+
yjs.bind(schema)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// ===========================================================================
|
|
172
|
+
// §3 — Cross-substrate: same schema, different bind targets
|
|
173
|
+
// ===========================================================================
|
|
174
|
+
|
|
175
|
+
describe("cross-substrate: universal schema vs substrate-specific schema", () => {
|
|
176
|
+
// A schema using only universally-supported features
|
|
177
|
+
const universalSchema = Schema.struct({
|
|
178
|
+
title: Schema.text(),
|
|
179
|
+
items: Schema.list(
|
|
180
|
+
Schema.struct({
|
|
181
|
+
name: Schema.string(),
|
|
182
|
+
done: Schema.boolean(),
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// A schema using Loro-specific features (counter, movable)
|
|
188
|
+
const loroSpecificSchema = Schema.struct({
|
|
189
|
+
title: Schema.text(),
|
|
190
|
+
count: Schema.counter(),
|
|
191
|
+
tasks: Schema.movableList(Schema.struct({ name: Schema.string() })),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("universal schema is Yjs-compatible (ExtractCaps check)", () => {
|
|
195
|
+
type Caps = ExtractCaps<typeof universalSchema>
|
|
196
|
+
// Only "text" — in YjsCaps
|
|
197
|
+
expectTypeOf<Caps>().toEqualTypeOf<"text">()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it("Loro-specific schema is NOT Yjs-compatible (ExtractCaps check)", () => {
|
|
201
|
+
type Caps = ExtractCaps<typeof loroSpecificSchema>
|
|
202
|
+
// Includes "counter" and "movable" which are NOT in YjsCaps
|
|
203
|
+
expectTypeOf<Caps>().toEqualTypeOf<"text" | "counter" | "movable">()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it("universal schema binds to yjs", () => {
|
|
207
|
+
const bound = yjs.bind(universalSchema)
|
|
208
|
+
expect(bound).toBeDefined()
|
|
209
|
+
expect(bound.schema).toBe(universalSchema)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("Loro-specific schema is rejected by yjs.bind()", () => {
|
|
213
|
+
// @ts-expect-error — counter and movable not in YjsCaps
|
|
214
|
+
yjs.bind(loroSpecificSchema)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it("json.bind() accepts schemas with all caps (AllowedCaps = string)", () => {
|
|
218
|
+
const schema = Schema.struct({
|
|
219
|
+
title: Schema.text(),
|
|
220
|
+
count: Schema.counter(),
|
|
221
|
+
tasks: Schema.movableList(Schema.string()),
|
|
222
|
+
})
|
|
223
|
+
const bound = json.bind(schema)
|
|
224
|
+
expect(bound).toBeDefined()
|
|
225
|
+
expect(bound.schema).toBe(schema)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ===========================================================================
|
|
230
|
+
// §4 — Edge cases: discriminated unions, multiple text fields
|
|
231
|
+
// ===========================================================================
|
|
232
|
+
|
|
233
|
+
describe("bind constraint edge cases", () => {
|
|
234
|
+
it("discriminated union with all-plain variants is accepted", () => {
|
|
235
|
+
const schema = Schema.struct({
|
|
236
|
+
content: Schema.discriminatedUnion("type", [
|
|
237
|
+
Schema.struct({
|
|
238
|
+
type: Schema.string("text"),
|
|
239
|
+
body: Schema.string(),
|
|
240
|
+
}),
|
|
241
|
+
Schema.struct({
|
|
242
|
+
type: Schema.string("image"),
|
|
243
|
+
url: Schema.string(),
|
|
244
|
+
}),
|
|
245
|
+
]),
|
|
246
|
+
})
|
|
247
|
+
const bound = yjs.bind(schema)
|
|
248
|
+
expect(bound).toBeDefined()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("struct with counter alongside plain variants is rejected", () => {
|
|
252
|
+
const schema = Schema.struct({
|
|
253
|
+
content: Schema.discriminatedUnion("type", [
|
|
254
|
+
Schema.struct({
|
|
255
|
+
type: Schema.string("text"),
|
|
256
|
+
body: Schema.string(),
|
|
257
|
+
}),
|
|
258
|
+
Schema.struct({
|
|
259
|
+
type: Schema.string("image"),
|
|
260
|
+
url: Schema.string(),
|
|
261
|
+
}),
|
|
262
|
+
]),
|
|
263
|
+
hits: Schema.counter(),
|
|
264
|
+
})
|
|
265
|
+
// @ts-expect-error — counter taints the whole schema
|
|
266
|
+
yjs.bind(schema)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("multiple text fields are all accepted", () => {
|
|
270
|
+
const schema = Schema.struct({
|
|
271
|
+
title: Schema.text(),
|
|
272
|
+
body: Schema.text(),
|
|
273
|
+
summary: Schema.text(),
|
|
274
|
+
})
|
|
275
|
+
const bound = yjs.bind(schema)
|
|
276
|
+
expect(bound).toBeDefined()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("plain-only schema (no caps at all) is accepted", () => {
|
|
280
|
+
const schema = Schema.struct({
|
|
281
|
+
name: Schema.string(),
|
|
282
|
+
age: Schema.number(),
|
|
283
|
+
active: Schema.boolean(),
|
|
284
|
+
tags: Schema.list(Schema.string()),
|
|
285
|
+
address: Schema.struct({
|
|
286
|
+
street: Schema.string(),
|
|
287
|
+
city: Schema.string(),
|
|
288
|
+
}),
|
|
289
|
+
metadata: Schema.record(Schema.any()),
|
|
290
|
+
})
|
|
291
|
+
const bound = yjs.bind(schema)
|
|
292
|
+
expect(bound).toBeDefined()
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// ===========================================================================
|
|
297
|
+
// §5 — Root kind rejection: bind() requires a product (struct) root
|
|
298
|
+
// ===========================================================================
|
|
299
|
+
|
|
300
|
+
describe("yjs.bind() rejects non-product root schemas", () => {
|
|
301
|
+
it("rejects bare list at root", () => {
|
|
302
|
+
// @ts-expect-error — SequenceSchema is not ProductSchema
|
|
303
|
+
yjs.bind(Schema.list(Schema.string()))
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it("rejects bare record at root", () => {
|
|
307
|
+
// @ts-expect-error — MapSchema is not ProductSchema
|
|
308
|
+
yjs.bind(Schema.record(Schema.string()))
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it("rejects bare text at root", () => {
|
|
312
|
+
// @ts-expect-error — TextSchema is not ProductSchema
|
|
313
|
+
yjs.bind(Schema.text())
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it("rejects bare scalar at root", () => {
|
|
317
|
+
// @ts-expect-error — ScalarSchema is not ProductSchema
|
|
318
|
+
yjs.bind(Schema.string())
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("rejects list of structs at root", () => {
|
|
322
|
+
// @ts-expect-error — SequenceSchema<ProductSchema> is still not ProductSchema
|
|
323
|
+
yjs.bind(Schema.list(Schema.struct({ name: Schema.string() })))
|
|
324
|
+
})
|
|
325
|
+
})
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
change,
|
|
3
|
+
createRef,
|
|
4
|
+
exportEntirety,
|
|
5
|
+
RawPath,
|
|
6
|
+
Schema,
|
|
7
|
+
unwrap,
|
|
8
|
+
} from "@kyneta/schema"
|
|
2
9
|
import { describe, expect, it } from "vitest"
|
|
3
10
|
import * as Y from "yjs"
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
import { yjs } from "../bind-yjs.js"
|
|
12
|
+
|
|
13
|
+
// ===========================================================================
|
|
14
|
+
// Helper — createDoc using the generic API
|
|
15
|
+
// ===========================================================================
|
|
16
|
+
|
|
17
|
+
import { createDoc } from "@kyneta/schema"
|
|
8
18
|
|
|
9
19
|
// ===========================================================================
|
|
10
20
|
// Schemas used across tests
|
|
11
21
|
// ===========================================================================
|
|
12
22
|
|
|
13
|
-
const TodoSchema = Schema.
|
|
14
|
-
title: Schema.
|
|
23
|
+
const TodoSchema = Schema.struct({
|
|
24
|
+
title: Schema.text(),
|
|
15
25
|
items: Schema.list(
|
|
16
26
|
Schema.struct({
|
|
17
27
|
name: Schema.string(),
|
|
@@ -20,8 +30,8 @@ const TodoSchema = Schema.doc({
|
|
|
20
30
|
),
|
|
21
31
|
})
|
|
22
32
|
|
|
23
|
-
const SimpleSchema = Schema.
|
|
24
|
-
title: Schema.
|
|
33
|
+
const SimpleSchema = Schema.struct({
|
|
34
|
+
title: Schema.text(),
|
|
25
35
|
count: Schema.number(),
|
|
26
36
|
})
|
|
27
37
|
|
|
@@ -29,26 +39,26 @@ const SimpleSchema = Schema.doc({
|
|
|
29
39
|
// Tests
|
|
30
40
|
// ===========================================================================
|
|
31
41
|
|
|
32
|
-
describe("
|
|
42
|
+
describe("yjs.bind", () => {
|
|
33
43
|
// -------------------------------------------------------------------------
|
|
34
44
|
// BoundSchema shape
|
|
35
45
|
// -------------------------------------------------------------------------
|
|
36
46
|
|
|
37
47
|
describe("BoundSchema", () => {
|
|
38
|
-
it("creates BoundSchema with
|
|
39
|
-
const bound =
|
|
48
|
+
it("creates BoundSchema with collaborative strategy", () => {
|
|
49
|
+
const bound = yjs.bind(TodoSchema)
|
|
40
50
|
expect(bound._brand).toBe("BoundSchema")
|
|
41
51
|
expect(bound.schema).toBe(TodoSchema)
|
|
42
|
-
expect(bound.strategy).toBe("
|
|
52
|
+
expect(bound.strategy).toBe("collaborative")
|
|
43
53
|
})
|
|
44
54
|
|
|
45
55
|
it("has a factory builder function", () => {
|
|
46
|
-
const bound =
|
|
56
|
+
const bound = yjs.bind(TodoSchema)
|
|
47
57
|
expect(typeof bound.factory).toBe("function")
|
|
48
58
|
})
|
|
49
59
|
|
|
50
60
|
it("preserves the schema reference", () => {
|
|
51
|
-
const bound =
|
|
61
|
+
const bound = yjs.bind(SimpleSchema)
|
|
52
62
|
expect(bound.schema).toBe(SimpleSchema)
|
|
53
63
|
})
|
|
54
64
|
})
|
|
@@ -59,10 +69,10 @@ describe("bindYjs", () => {
|
|
|
59
69
|
|
|
60
70
|
describe("factory builder", () => {
|
|
61
71
|
it("produces a working SubstrateFactory", () => {
|
|
62
|
-
const bound =
|
|
72
|
+
const bound = yjs.bind(SimpleSchema)
|
|
63
73
|
const factory = bound.factory({ peerId: "peer-1" })
|
|
64
74
|
|
|
65
|
-
const
|
|
75
|
+
const _substrate = factory.create(SimpleSchema)
|
|
66
76
|
|
|
67
77
|
// Populate via the substrate's writable context
|
|
68
78
|
const doc = createYjsDocFromFactory(factory, SimpleSchema)
|
|
@@ -76,7 +86,7 @@ describe("bindYjs", () => {
|
|
|
76
86
|
})
|
|
77
87
|
|
|
78
88
|
it("factory supports fromEntirety", () => {
|
|
79
|
-
const bound =
|
|
89
|
+
const bound = yjs.bind(SimpleSchema)
|
|
80
90
|
const factory = bound.factory({ peerId: "peer-1" })
|
|
81
91
|
|
|
82
92
|
// Create and populate
|
|
@@ -85,8 +95,7 @@ describe("bindYjs", () => {
|
|
|
85
95
|
d.title.insert(0, "Snap")
|
|
86
96
|
d.count.set(42)
|
|
87
97
|
})
|
|
88
|
-
const
|
|
89
|
-
const snapshot = substrate1.exportEntirety()
|
|
98
|
+
const snapshot = exportEntirety(doc1)
|
|
90
99
|
|
|
91
100
|
// Restore
|
|
92
101
|
const substrate2 = factory.fromEntirety(snapshot, SimpleSchema)
|
|
@@ -95,7 +104,7 @@ describe("bindYjs", () => {
|
|
|
95
104
|
})
|
|
96
105
|
|
|
97
106
|
it("factory supports parseVersion", () => {
|
|
98
|
-
const bound =
|
|
107
|
+
const bound = yjs.bind(SimpleSchema)
|
|
99
108
|
const factory = bound.factory({ peerId: "peer-1" })
|
|
100
109
|
|
|
101
110
|
const substrate = factory.create(SimpleSchema)
|
|
@@ -112,34 +121,44 @@ describe("bindYjs", () => {
|
|
|
112
121
|
|
|
113
122
|
describe("deterministic clientID", () => {
|
|
114
123
|
it("same peerId produces same clientID across multiple factory calls", () => {
|
|
115
|
-
const bound =
|
|
124
|
+
const bound = yjs.bind(SimpleSchema)
|
|
116
125
|
const factory = bound.factory({ peerId: "stable-peer-id" })
|
|
117
126
|
|
|
118
|
-
const
|
|
119
|
-
const
|
|
127
|
+
const _s1 = factory.create(SimpleSchema)
|
|
128
|
+
const _s2 = factory.create(SimpleSchema)
|
|
120
129
|
|
|
121
130
|
// Both docs should have the same clientID
|
|
122
|
-
const doc1 =
|
|
123
|
-
|
|
131
|
+
const doc1 = unwrap(
|
|
132
|
+
createYjsDocFromFactory(factory, SimpleSchema),
|
|
133
|
+
) as Y.Doc
|
|
134
|
+
const doc2 = unwrap(
|
|
135
|
+
createYjsDocFromFactory(factory, SimpleSchema),
|
|
136
|
+
) as Y.Doc
|
|
124
137
|
|
|
125
138
|
expect(doc1.clientID).toBe(doc2.clientID)
|
|
126
139
|
})
|
|
127
140
|
|
|
128
141
|
it("different peerIds produce different clientIDs", () => {
|
|
129
|
-
const bound =
|
|
142
|
+
const bound = yjs.bind(SimpleSchema)
|
|
130
143
|
const factory1 = bound.factory({ peerId: "peer-alpha" })
|
|
131
144
|
const factory2 = bound.factory({ peerId: "peer-beta" })
|
|
132
145
|
|
|
133
|
-
const doc1 =
|
|
134
|
-
|
|
146
|
+
const doc1 = unwrap(
|
|
147
|
+
createYjsDocFromFactory(factory1, SimpleSchema),
|
|
148
|
+
) as Y.Doc
|
|
149
|
+
const doc2 = unwrap(
|
|
150
|
+
createYjsDocFromFactory(factory2, SimpleSchema),
|
|
151
|
+
) as Y.Doc
|
|
135
152
|
|
|
136
153
|
expect(doc1.clientID).not.toBe(doc2.clientID)
|
|
137
154
|
})
|
|
138
155
|
|
|
139
156
|
it("clientID is a valid uint32", () => {
|
|
140
|
-
const bound =
|
|
157
|
+
const bound = yjs.bind(SimpleSchema)
|
|
141
158
|
const factory = bound.factory({ peerId: "test-peer-id-12345" })
|
|
142
|
-
const doc =
|
|
159
|
+
const doc = unwrap(
|
|
160
|
+
createYjsDocFromFactory(factory, SimpleSchema),
|
|
161
|
+
) as Y.Doc
|
|
143
162
|
|
|
144
163
|
expect(typeof doc.clientID).toBe("number")
|
|
145
164
|
expect(doc.clientID).toBeGreaterThanOrEqual(0)
|
|
@@ -151,63 +170,72 @@ describe("bindYjs", () => {
|
|
|
151
170
|
const peerId = "deterministic-check-peer"
|
|
152
171
|
|
|
153
172
|
// Simulate two separate "sessions" — both should hash to the same value
|
|
154
|
-
const bound1 =
|
|
173
|
+
const bound1 = yjs.bind(SimpleSchema)
|
|
155
174
|
const factory1 = bound1.factory({ peerId })
|
|
156
|
-
const doc1 =
|
|
175
|
+
const doc1 = unwrap(
|
|
176
|
+
createYjsDocFromFactory(factory1, SimpleSchema),
|
|
177
|
+
) as Y.Doc
|
|
157
178
|
|
|
158
|
-
const bound2 =
|
|
179
|
+
const bound2 = yjs.bind(SimpleSchema)
|
|
159
180
|
const factory2 = bound2.factory({ peerId })
|
|
160
|
-
const doc2 =
|
|
181
|
+
const doc2 = unwrap(
|
|
182
|
+
createYjsDocFromFactory(factory2, SimpleSchema),
|
|
183
|
+
) as Y.Doc
|
|
161
184
|
|
|
162
185
|
expect(doc1.clientID).toBe(doc2.clientID)
|
|
163
186
|
})
|
|
164
187
|
})
|
|
165
188
|
|
|
166
189
|
// -------------------------------------------------------------------------
|
|
167
|
-
//
|
|
190
|
+
// unwrap() escape hatch
|
|
168
191
|
// -------------------------------------------------------------------------
|
|
169
192
|
|
|
170
|
-
describe("
|
|
171
|
-
it("returns the underlying Y.Doc from a
|
|
172
|
-
const doc =
|
|
193
|
+
describe("unwrap() escape hatch", () => {
|
|
194
|
+
it("returns the underlying Y.Doc from a createDoc ref", () => {
|
|
195
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
173
196
|
change(doc, (d: any) => {
|
|
174
197
|
d.title.insert(0, "Escape")
|
|
175
198
|
d.count.set(0)
|
|
176
199
|
})
|
|
177
|
-
const yjsDoc =
|
|
200
|
+
const yjsDoc = unwrap(doc) as Y.Doc
|
|
178
201
|
|
|
179
202
|
expect(yjsDoc).toBeInstanceOf(Y.Doc)
|
|
180
203
|
expect(yjsDoc.getMap("root").get("count")).toBe(0)
|
|
181
204
|
})
|
|
182
205
|
|
|
183
206
|
it("returns a Y.Doc with the correct root map state", () => {
|
|
184
|
-
const doc =
|
|
207
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
185
208
|
change(doc, (d: any) => {
|
|
186
209
|
d.title.insert(0, "Hello")
|
|
187
210
|
d.count.set(42)
|
|
188
211
|
})
|
|
189
|
-
const yjsDoc =
|
|
212
|
+
const yjsDoc = unwrap(doc) as Y.Doc
|
|
190
213
|
const rootMap = yjsDoc.getMap("root")
|
|
191
214
|
|
|
192
215
|
expect((rootMap.get("title") as Y.Text).toJSON()).toBe("Hello")
|
|
193
216
|
expect(rootMap.get("count")).toBe(42)
|
|
194
217
|
})
|
|
195
218
|
|
|
196
|
-
it("
|
|
197
|
-
expect((
|
|
219
|
+
it("returns undefined for non-refs (plain object)", () => {
|
|
220
|
+
expect(unwrap({} as any)).toBeUndefined()
|
|
198
221
|
})
|
|
199
222
|
|
|
200
|
-
it("
|
|
223
|
+
it("returns undefined for non-refs (random object with properties)", () => {
|
|
201
224
|
const fake = {
|
|
202
225
|
title: () => "fake",
|
|
203
226
|
count: () => 0,
|
|
204
227
|
}
|
|
205
|
-
expect((
|
|
228
|
+
expect(unwrap(fake as any)).toBeUndefined()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it("throws for primitives", () => {
|
|
232
|
+
expect(() => unwrap(null as any)).toThrow("unwrap() requires a ref")
|
|
233
|
+
expect(() => unwrap(undefined as any)).toThrow("unwrap() requires a ref")
|
|
206
234
|
})
|
|
207
235
|
|
|
208
236
|
it("mutations through escape hatch are visible via kyneta ref", () => {
|
|
209
|
-
const doc =
|
|
210
|
-
const yjsDoc =
|
|
237
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
238
|
+
const yjsDoc = unwrap(doc) as Y.Doc
|
|
211
239
|
|
|
212
240
|
// Mutate via raw Yjs
|
|
213
241
|
yjsDoc.getMap("root").set("count", 99)
|
|
@@ -215,11 +243,11 @@ describe("bindYjs", () => {
|
|
|
215
243
|
})
|
|
216
244
|
|
|
217
245
|
it("text mutations through escape hatch are visible", () => {
|
|
218
|
-
const doc =
|
|
246
|
+
const doc = createDoc(yjs.bind(SimpleSchema))
|
|
219
247
|
change(doc, (d: any) => {
|
|
220
248
|
d.title.insert(0, "Hello")
|
|
221
249
|
})
|
|
222
|
-
const yjsDoc =
|
|
250
|
+
const yjsDoc = unwrap(doc) as Y.Doc
|
|
223
251
|
|
|
224
252
|
const text = yjsDoc.getMap("root").get("title") as Y.Text
|
|
225
253
|
text.insert(5, " World")
|
|
@@ -233,14 +261,6 @@ describe("bindYjs", () => {
|
|
|
233
261
|
// ===========================================================================
|
|
234
262
|
|
|
235
263
|
import type { Schema as SchemaType, SubstrateFactory } from "@kyneta/schema"
|
|
236
|
-
import {
|
|
237
|
-
changefeed,
|
|
238
|
-
interpret,
|
|
239
|
-
readable,
|
|
240
|
-
registerSubstrate,
|
|
241
|
-
unwrap,
|
|
242
|
-
writable,
|
|
243
|
-
} from "@kyneta/schema"
|
|
244
264
|
|
|
245
265
|
/**
|
|
246
266
|
* Helper to create a kyneta ref from a factory (mimicking what exchange.get does).
|
|
@@ -251,16 +271,5 @@ function createYjsDocFromFactory(
|
|
|
251
271
|
schema: SchemaType,
|
|
252
272
|
): any {
|
|
253
273
|
const substrate = factory.create(schema)
|
|
254
|
-
|
|
255
|
-
.with(readable)
|
|
256
|
-
.with(writable)
|
|
257
|
-
.with(changefeed)
|
|
258
|
-
.done()
|
|
259
|
-
|
|
260
|
-
// Register for escape hatch — createYjsSubstrate already registered
|
|
261
|
-
// substrate → Y.Doc internally, but we also need ref → substrate
|
|
262
|
-
// for unwrap() (used by the yjs() escape hatch).
|
|
263
|
-
registerSubstrate(doc, substrate)
|
|
264
|
-
|
|
265
|
-
return doc
|
|
274
|
+
return createRef(schema, substrate)
|
|
266
275
|
}
|