@kyneta/yjs-schema 1.3.1 → 1.4.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 +28 -25
- package/dist/index.d.ts +185 -86
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1159 -860
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/bind-constraints.test.ts +39 -30
- package/src/__tests__/bind-yjs.test.ts +53 -16
- package/src/__tests__/position.test.ts +376 -0
- package/src/__tests__/structural-merge.test.ts +111 -54
- package/src/__tests__/substrate.test.ts +18 -0
- package/src/__tests__/version.test.ts +87 -0
- package/src/bind-yjs.ts +44 -37
- package/src/change-mapping.ts +219 -25
- package/src/index.ts +3 -1
- package/src/populate.ts +59 -12
- package/src/position.ts +45 -0
- package/src/reader.ts +62 -6
- package/src/substrate.ts +99 -11
- package/src/version.ts +135 -33
- package/src/yjs-resolve.ts +59 -12
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// bind-constraints — compile-time and runtime tests for yjs.bind()
|
|
1
|
+
// bind-constraints — compile-time and runtime tests for yjs.bind() laws enforcement.
|
|
2
2
|
//
|
|
3
|
-
// Verifies that `yjs.bind()` rejects schemas containing
|
|
4
|
-
// that Yjs doesn't support (
|
|
5
|
-
// via the `
|
|
6
|
-
//
|
|
3
|
+
// Verifies that `yjs.bind()` rejects schemas containing composition laws
|
|
4
|
+
// that Yjs doesn't support (additive, positional-ot-move, tree-move,
|
|
5
|
+
// add-wins-per-key) at COMPILE TIME via the `RestrictLaws` / `AllowedLaws`
|
|
6
|
+
// mechanism, while accepting laws it does support (lww, lww-per-key,
|
|
7
|
+
// positional-ot, lww-tag-replaced) and plain schemas.
|
|
7
8
|
|
|
8
9
|
import {
|
|
9
10
|
type BoundSchema,
|
|
10
|
-
type
|
|
11
|
+
type ExtractLaws,
|
|
11
12
|
json,
|
|
12
13
|
Schema,
|
|
13
14
|
} from "@kyneta/schema"
|
|
@@ -105,36 +106,36 @@ describe("yjs.bind() accepts Yjs-compatible schemas", () => {
|
|
|
105
106
|
// §2 — Compile-time rejection: schemas that yjs.bind() SHOULD reject
|
|
106
107
|
// ===========================================================================
|
|
107
108
|
|
|
108
|
-
describe("yjs.bind() rejects schemas with unsupported
|
|
109
|
-
it("rejects counter", () => {
|
|
109
|
+
describe("yjs.bind() rejects schemas with unsupported composition laws", () => {
|
|
110
|
+
it("rejects counter (additive not in YjsLaws)", () => {
|
|
110
111
|
const schema = Schema.struct({
|
|
111
112
|
count: Schema.counter(),
|
|
112
113
|
})
|
|
113
|
-
// @ts-expect-error —
|
|
114
|
+
// @ts-expect-error — "additive" is not in YjsLaws
|
|
114
115
|
yjs.bind(schema)
|
|
115
116
|
})
|
|
116
117
|
|
|
117
|
-
it("rejects movableList", () => {
|
|
118
|
+
it("rejects movableList (positional-ot-move not in YjsLaws)", () => {
|
|
118
119
|
const schema = Schema.struct({
|
|
119
120
|
items: Schema.movableList(Schema.string()),
|
|
120
121
|
})
|
|
121
|
-
// @ts-expect-error —
|
|
122
|
+
// @ts-expect-error — "positional-ot-move" is not in YjsLaws
|
|
122
123
|
yjs.bind(schema)
|
|
123
124
|
})
|
|
124
125
|
|
|
125
|
-
it("rejects tree", () => {
|
|
126
|
+
it("rejects tree (tree-move not in YjsLaws)", () => {
|
|
126
127
|
const schema = Schema.struct({
|
|
127
128
|
hierarchy: Schema.tree(Schema.struct({ label: Schema.string() })),
|
|
128
129
|
})
|
|
129
|
-
// @ts-expect-error — tree is not in
|
|
130
|
+
// @ts-expect-error — "tree-move" is not in YjsLaws
|
|
130
131
|
yjs.bind(schema)
|
|
131
132
|
})
|
|
132
133
|
|
|
133
|
-
it("rejects set", () => {
|
|
134
|
+
it("rejects set (add-wins-per-key not in YjsLaws)", () => {
|
|
134
135
|
const schema = Schema.struct({
|
|
135
136
|
tags: Schema.set(Schema.string()),
|
|
136
137
|
})
|
|
137
|
-
// @ts-expect-error —
|
|
138
|
+
// @ts-expect-error — "add-wins-per-key" is not in YjsLaws
|
|
138
139
|
yjs.bind(schema)
|
|
139
140
|
})
|
|
140
141
|
|
|
@@ -146,7 +147,7 @@ describe("yjs.bind() rejects schemas with unsupported caps", () => {
|
|
|
146
147
|
}),
|
|
147
148
|
),
|
|
148
149
|
})
|
|
149
|
-
// @ts-expect-error —
|
|
150
|
+
// @ts-expect-error — "additive" is deeply nested but still caught
|
|
150
151
|
yjs.bind(schema)
|
|
151
152
|
})
|
|
152
153
|
|
|
@@ -155,7 +156,7 @@ describe("yjs.bind() rejects schemas with unsupported caps", () => {
|
|
|
155
156
|
title: Schema.text(),
|
|
156
157
|
views: Schema.counter(),
|
|
157
158
|
})
|
|
158
|
-
// @ts-expect-error —
|
|
159
|
+
// @ts-expect-error — "additive" is not in YjsLaws
|
|
159
160
|
yjs.bind(schema)
|
|
160
161
|
})
|
|
161
162
|
|
|
@@ -163,7 +164,7 @@ describe("yjs.bind() rejects schemas with unsupported caps", () => {
|
|
|
163
164
|
const schema = Schema.struct({
|
|
164
165
|
scores: Schema.list(Schema.struct({ value: Schema.counter() })),
|
|
165
166
|
})
|
|
166
|
-
// @ts-expect-error —
|
|
167
|
+
// @ts-expect-error — "additive" nested inside list struct
|
|
167
168
|
yjs.bind(schema)
|
|
168
169
|
})
|
|
169
170
|
})
|
|
@@ -191,16 +192,24 @@ describe("cross-substrate: universal schema vs substrate-specific schema", () =>
|
|
|
191
192
|
tasks: Schema.movableList(Schema.struct({ name: Schema.string() })),
|
|
192
193
|
})
|
|
193
194
|
|
|
194
|
-
it("universal schema is Yjs-compatible (
|
|
195
|
-
type
|
|
196
|
-
//
|
|
197
|
-
expectTypeOf<
|
|
195
|
+
it("universal schema is Yjs-compatible (ExtractLaws check)", () => {
|
|
196
|
+
type Laws = ExtractLaws<typeof universalSchema>
|
|
197
|
+
// lww-per-key (struct), positional-ot (text, list), lww (scalars) — all in YjsLaws
|
|
198
|
+
expectTypeOf<Laws>().toEqualTypeOf<
|
|
199
|
+
"lww-per-key" | "positional-ot" | "lww"
|
|
200
|
+
>()
|
|
198
201
|
})
|
|
199
202
|
|
|
200
|
-
it("Loro-specific schema is NOT Yjs-compatible (
|
|
201
|
-
type
|
|
202
|
-
// Includes "
|
|
203
|
-
expectTypeOf<
|
|
203
|
+
it("Loro-specific schema is NOT Yjs-compatible (ExtractLaws check)", () => {
|
|
204
|
+
type Laws = ExtractLaws<typeof loroSpecificSchema>
|
|
205
|
+
// Includes "additive" and "positional-ot-move" which are NOT in YjsLaws
|
|
206
|
+
expectTypeOf<Laws>().toEqualTypeOf<
|
|
207
|
+
| "lww-per-key"
|
|
208
|
+
| "positional-ot"
|
|
209
|
+
| "additive"
|
|
210
|
+
| "positional-ot-move"
|
|
211
|
+
| "lww"
|
|
212
|
+
>()
|
|
204
213
|
})
|
|
205
214
|
|
|
206
215
|
it("universal schema binds to yjs", () => {
|
|
@@ -210,11 +219,11 @@ describe("cross-substrate: universal schema vs substrate-specific schema", () =>
|
|
|
210
219
|
})
|
|
211
220
|
|
|
212
221
|
it("Loro-specific schema is rejected by yjs.bind()", () => {
|
|
213
|
-
// @ts-expect-error —
|
|
222
|
+
// @ts-expect-error — "additive" and "positional-ot-move" not in YjsLaws
|
|
214
223
|
yjs.bind(loroSpecificSchema)
|
|
215
224
|
})
|
|
216
225
|
|
|
217
|
-
it("json.bind() accepts schemas with all
|
|
226
|
+
it("json.bind() accepts schemas with all laws (AllowedLaws = string)", () => {
|
|
218
227
|
const schema = Schema.struct({
|
|
219
228
|
title: Schema.text(),
|
|
220
229
|
count: Schema.counter(),
|
|
@@ -262,7 +271,7 @@ describe("bind constraint edge cases", () => {
|
|
|
262
271
|
]),
|
|
263
272
|
hits: Schema.counter(),
|
|
264
273
|
})
|
|
265
|
-
// @ts-expect-error —
|
|
274
|
+
// @ts-expect-error — "additive" taints the whole schema
|
|
266
275
|
yjs.bind(schema)
|
|
267
276
|
})
|
|
268
277
|
|
|
@@ -276,7 +285,7 @@ describe("bind constraint edge cases", () => {
|
|
|
276
285
|
expect(bound).toBeDefined()
|
|
277
286
|
})
|
|
278
287
|
|
|
279
|
-
it("plain-only schema (no
|
|
288
|
+
it("plain-only schema (no laws at all) is accepted", () => {
|
|
280
289
|
const schema = Schema.struct({
|
|
281
290
|
name: Schema.string(),
|
|
282
291
|
age: Schema.number(),
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
change,
|
|
3
3
|
createRef,
|
|
4
|
+
deriveIdentity,
|
|
4
5
|
exportEntirety,
|
|
5
6
|
RawPath,
|
|
6
7
|
Schema,
|
|
8
|
+
SYNC_COLLABORATIVE,
|
|
7
9
|
unwrap,
|
|
8
10
|
} from "@kyneta/schema"
|
|
9
11
|
import { describe, expect, it } from "vitest"
|
|
10
12
|
import * as Y from "yjs"
|
|
11
13
|
import { yjs } from "../bind-yjs.js"
|
|
12
14
|
|
|
15
|
+
// ===========================================================================
|
|
16
|
+
// Identity-keying helpers
|
|
17
|
+
// ===========================================================================
|
|
18
|
+
|
|
19
|
+
function id(fieldName: string): string {
|
|
20
|
+
return deriveIdentity(fieldName, 1)
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
// ===========================================================================
|
|
14
24
|
// Helper — createDoc using the generic API
|
|
15
25
|
// ===========================================================================
|
|
@@ -45,11 +55,11 @@ describe("yjs.bind", () => {
|
|
|
45
55
|
// -------------------------------------------------------------------------
|
|
46
56
|
|
|
47
57
|
describe("BoundSchema", () => {
|
|
48
|
-
it("creates BoundSchema with collaborative
|
|
58
|
+
it("creates BoundSchema with collaborative sync protocol", () => {
|
|
49
59
|
const bound = yjs.bind(TodoSchema)
|
|
50
60
|
expect(bound._brand).toBe("BoundSchema")
|
|
51
61
|
expect(bound.schema).toBe(TodoSchema)
|
|
52
|
-
expect(bound.
|
|
62
|
+
expect(bound.syncProtocol).toEqual(SYNC_COLLABORATIVE)
|
|
53
63
|
})
|
|
54
64
|
|
|
55
65
|
it("has a factory builder function", () => {
|
|
@@ -70,7 +80,10 @@ describe("yjs.bind", () => {
|
|
|
70
80
|
describe("factory builder", () => {
|
|
71
81
|
it("produces a working SubstrateFactory", () => {
|
|
72
82
|
const bound = yjs.bind(SimpleSchema)
|
|
73
|
-
const factory = bound.factory({
|
|
83
|
+
const factory = bound.factory({
|
|
84
|
+
peerId: "peer-1",
|
|
85
|
+
binding: bound.identityBinding,
|
|
86
|
+
})
|
|
74
87
|
|
|
75
88
|
const _substrate = factory.create(SimpleSchema)
|
|
76
89
|
|
|
@@ -87,7 +100,10 @@ describe("yjs.bind", () => {
|
|
|
87
100
|
|
|
88
101
|
it("factory supports fromEntirety", () => {
|
|
89
102
|
const bound = yjs.bind(SimpleSchema)
|
|
90
|
-
const factory = bound.factory({
|
|
103
|
+
const factory = bound.factory({
|
|
104
|
+
peerId: "peer-1",
|
|
105
|
+
binding: bound.identityBinding,
|
|
106
|
+
})
|
|
91
107
|
|
|
92
108
|
// Create and populate
|
|
93
109
|
const doc1 = createYjsDocFromFactory(factory, SimpleSchema)
|
|
@@ -105,7 +121,10 @@ describe("yjs.bind", () => {
|
|
|
105
121
|
|
|
106
122
|
it("factory supports parseVersion", () => {
|
|
107
123
|
const bound = yjs.bind(SimpleSchema)
|
|
108
|
-
const factory = bound.factory({
|
|
124
|
+
const factory = bound.factory({
|
|
125
|
+
peerId: "peer-1",
|
|
126
|
+
binding: bound.identityBinding,
|
|
127
|
+
})
|
|
109
128
|
|
|
110
129
|
const substrate = factory.create(SimpleSchema)
|
|
111
130
|
const v = substrate.version()
|
|
@@ -122,7 +141,10 @@ describe("yjs.bind", () => {
|
|
|
122
141
|
describe("deterministic clientID", () => {
|
|
123
142
|
it("same peerId produces same clientID across multiple factory calls", () => {
|
|
124
143
|
const bound = yjs.bind(SimpleSchema)
|
|
125
|
-
const factory = bound.factory({
|
|
144
|
+
const factory = bound.factory({
|
|
145
|
+
peerId: "stable-peer-id",
|
|
146
|
+
binding: bound.identityBinding,
|
|
147
|
+
})
|
|
126
148
|
|
|
127
149
|
const _s1 = factory.create(SimpleSchema)
|
|
128
150
|
const _s2 = factory.create(SimpleSchema)
|
|
@@ -140,8 +162,14 @@ describe("yjs.bind", () => {
|
|
|
140
162
|
|
|
141
163
|
it("different peerIds produce different clientIDs", () => {
|
|
142
164
|
const bound = yjs.bind(SimpleSchema)
|
|
143
|
-
const factory1 = bound.factory({
|
|
144
|
-
|
|
165
|
+
const factory1 = bound.factory({
|
|
166
|
+
peerId: "peer-alpha",
|
|
167
|
+
binding: bound.identityBinding,
|
|
168
|
+
})
|
|
169
|
+
const factory2 = bound.factory({
|
|
170
|
+
peerId: "peer-beta",
|
|
171
|
+
binding: bound.identityBinding,
|
|
172
|
+
})
|
|
145
173
|
|
|
146
174
|
const doc1 = unwrap(
|
|
147
175
|
createYjsDocFromFactory(factory1, SimpleSchema),
|
|
@@ -155,7 +183,10 @@ describe("yjs.bind", () => {
|
|
|
155
183
|
|
|
156
184
|
it("clientID is a valid uint32", () => {
|
|
157
185
|
const bound = yjs.bind(SimpleSchema)
|
|
158
|
-
const factory = bound.factory({
|
|
186
|
+
const factory = bound.factory({
|
|
187
|
+
peerId: "test-peer-id-12345",
|
|
188
|
+
binding: bound.identityBinding,
|
|
189
|
+
})
|
|
159
190
|
const doc = unwrap(
|
|
160
191
|
createYjsDocFromFactory(factory, SimpleSchema),
|
|
161
192
|
) as Y.Doc
|
|
@@ -171,13 +202,19 @@ describe("yjs.bind", () => {
|
|
|
171
202
|
|
|
172
203
|
// Simulate two separate "sessions" — both should hash to the same value
|
|
173
204
|
const bound1 = yjs.bind(SimpleSchema)
|
|
174
|
-
const factory1 = bound1.factory({
|
|
205
|
+
const factory1 = bound1.factory({
|
|
206
|
+
peerId,
|
|
207
|
+
binding: bound1.identityBinding,
|
|
208
|
+
})
|
|
175
209
|
const doc1 = unwrap(
|
|
176
210
|
createYjsDocFromFactory(factory1, SimpleSchema),
|
|
177
211
|
) as Y.Doc
|
|
178
212
|
|
|
179
213
|
const bound2 = yjs.bind(SimpleSchema)
|
|
180
|
-
const factory2 = bound2.factory({
|
|
214
|
+
const factory2 = bound2.factory({
|
|
215
|
+
peerId,
|
|
216
|
+
binding: bound2.identityBinding,
|
|
217
|
+
})
|
|
181
218
|
const doc2 = unwrap(
|
|
182
219
|
createYjsDocFromFactory(factory2, SimpleSchema),
|
|
183
220
|
) as Y.Doc
|
|
@@ -200,7 +237,7 @@ describe("yjs.bind", () => {
|
|
|
200
237
|
const yjsDoc = unwrap(doc) as Y.Doc
|
|
201
238
|
|
|
202
239
|
expect(yjsDoc).toBeInstanceOf(Y.Doc)
|
|
203
|
-
expect(yjsDoc.getMap("root").get("count")).toBe(0)
|
|
240
|
+
expect(yjsDoc.getMap("root").get(id("count"))).toBe(0)
|
|
204
241
|
})
|
|
205
242
|
|
|
206
243
|
it("returns a Y.Doc with the correct root map state", () => {
|
|
@@ -212,8 +249,8 @@ describe("yjs.bind", () => {
|
|
|
212
249
|
const yjsDoc = unwrap(doc) as Y.Doc
|
|
213
250
|
const rootMap = yjsDoc.getMap("root")
|
|
214
251
|
|
|
215
|
-
expect((rootMap.get("title") as Y.Text).toJSON()).toBe("Hello")
|
|
216
|
-
expect(rootMap.get("count")).toBe(42)
|
|
252
|
+
expect((rootMap.get(id("title")) as Y.Text).toJSON()).toBe("Hello")
|
|
253
|
+
expect(rootMap.get(id("count"))).toBe(42)
|
|
217
254
|
})
|
|
218
255
|
|
|
219
256
|
it("returns undefined for non-refs (plain object)", () => {
|
|
@@ -238,7 +275,7 @@ describe("yjs.bind", () => {
|
|
|
238
275
|
const yjsDoc = unwrap(doc) as Y.Doc
|
|
239
276
|
|
|
240
277
|
// Mutate via raw Yjs
|
|
241
|
-
yjsDoc.getMap("root").set("count", 99)
|
|
278
|
+
yjsDoc.getMap("root").set(id("count"), 99)
|
|
242
279
|
expect(doc.count()).toBe(99)
|
|
243
280
|
})
|
|
244
281
|
|
|
@@ -249,7 +286,7 @@ describe("yjs.bind", () => {
|
|
|
249
286
|
})
|
|
250
287
|
const yjsDoc = unwrap(doc) as Y.Doc
|
|
251
288
|
|
|
252
|
-
const text = yjsDoc.getMap("root").get("title") as Y.Text
|
|
289
|
+
const text = yjsDoc.getMap("root").get(id("title")) as Y.Text
|
|
253
290
|
text.insert(5, " World")
|
|
254
291
|
expect(doc.title()).toBe("Hello World")
|
|
255
292
|
})
|