@kyneta/yjs-schema 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/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +865 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/__tests__/bind-yjs.test.ts +266 -0
- package/src/__tests__/create.test.ts +632 -0
- package/src/__tests__/record-text-spike.test.ts +429 -0
- package/src/__tests__/store-reader.test.ts +722 -0
- package/src/__tests__/substrate.test.ts +604 -0
- package/src/__tests__/version.test.ts +227 -0
- package/src/bind-yjs.ts +147 -0
- package/src/change-mapping.ts +612 -0
- package/src/create.ts +172 -0
- package/src/index.ts +83 -0
- package/src/populate.ts +208 -0
- package/src/store-reader.ts +123 -0
- package/src/substrate.ts +252 -0
- package/src/sync.ts +107 -0
- package/src/version.ts +138 -0
- package/src/yjs-escape.ts +100 -0
- package/src/yjs-resolve.ts +108 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as Y from "yjs"
|
|
3
|
+
import { YjsVersion } from "../version.js"
|
|
4
|
+
|
|
5
|
+
// ===========================================================================
|
|
6
|
+
// Helpers
|
|
7
|
+
// ===========================================================================
|
|
8
|
+
|
|
9
|
+
/** Create a YjsVersion from a doc that has had some operations applied. */
|
|
10
|
+
function versionAfterOps(fn: (doc: Y.Doc) => void): YjsVersion {
|
|
11
|
+
const doc = new Y.Doc()
|
|
12
|
+
fn(doc)
|
|
13
|
+
return new YjsVersion(Y.encodeStateVector(doc))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Create a YjsVersion from an empty doc (no operations). */
|
|
17
|
+
function emptyVersion(): YjsVersion {
|
|
18
|
+
return new YjsVersion(Y.encodeStateVector(new Y.Doc()))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ===========================================================================
|
|
22
|
+
// YjsVersion
|
|
23
|
+
// ===========================================================================
|
|
24
|
+
|
|
25
|
+
describe("YjsVersion", () => {
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
// serialize / parse round-trip
|
|
28
|
+
// -------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe("serialize / parse", () => {
|
|
31
|
+
it("round-trips an empty version vector", () => {
|
|
32
|
+
const v = emptyVersion()
|
|
33
|
+
const serialized = v.serialize()
|
|
34
|
+
const parsed = YjsVersion.parse(serialized)
|
|
35
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("round-trips a version vector with one peer", () => {
|
|
39
|
+
const v = versionAfterOps((doc) => {
|
|
40
|
+
doc.getMap("root").set("title", "Hello")
|
|
41
|
+
})
|
|
42
|
+
const serialized = v.serialize()
|
|
43
|
+
expect(typeof serialized).toBe("string")
|
|
44
|
+
expect(serialized.length).toBeGreaterThan(0)
|
|
45
|
+
|
|
46
|
+
const parsed = YjsVersion.parse(serialized)
|
|
47
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
48
|
+
expect(v.compare(parsed)).toBe("equal")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("round-trips a version vector with multiple peers", () => {
|
|
52
|
+
const doc1 = new Y.Doc()
|
|
53
|
+
const doc2 = new Y.Doc()
|
|
54
|
+
|
|
55
|
+
doc1.getMap("root").set("title", "A")
|
|
56
|
+
doc2.getMap("root").set("title", "B")
|
|
57
|
+
|
|
58
|
+
// Sync doc1 ← doc2
|
|
59
|
+
const update = Y.encodeStateAsUpdate(doc2, Y.encodeStateVector(doc1))
|
|
60
|
+
Y.applyUpdate(doc1, update)
|
|
61
|
+
|
|
62
|
+
// doc1 now has ops from both peers
|
|
63
|
+
const v = new YjsVersion(Y.encodeStateVector(doc1))
|
|
64
|
+
const parsed = YjsVersion.parse(v.serialize())
|
|
65
|
+
expect(parsed.compare(v)).toBe("equal")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("serialized form is a non-empty string", () => {
|
|
69
|
+
const v = versionAfterOps((doc) => {
|
|
70
|
+
doc.getMap("root").set("count", 42)
|
|
71
|
+
})
|
|
72
|
+
const s = v.serialize()
|
|
73
|
+
expect(typeof s).toBe("string")
|
|
74
|
+
expect(s.length).toBeGreaterThan(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("parse throws on empty string", () => {
|
|
78
|
+
expect(() => YjsVersion.parse("")).toThrow("empty string")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("parse throws on invalid base64", () => {
|
|
82
|
+
expect(() => YjsVersion.parse("!!!not-base64!!!")).toThrow()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// compare
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
describe("compare", () => {
|
|
91
|
+
it("returns 'equal' for the same version vector", () => {
|
|
92
|
+
const v = versionAfterOps((doc) => {
|
|
93
|
+
doc.getMap("root").set("t", "hi")
|
|
94
|
+
})
|
|
95
|
+
expect(v.compare(v)).toBe("equal")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("returns 'equal' for two independently constructed identical VVs", () => {
|
|
99
|
+
const v1 = emptyVersion()
|
|
100
|
+
const v2 = emptyVersion()
|
|
101
|
+
expect(v1.compare(v2)).toBe("equal")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("returns 'behind' / 'ahead' for causally ordered versions (single peer)", () => {
|
|
105
|
+
const doc = new Y.Doc()
|
|
106
|
+
const root = doc.getMap("root")
|
|
107
|
+
root.set("title", "A")
|
|
108
|
+
const v1 = new YjsVersion(Y.encodeStateVector(doc))
|
|
109
|
+
|
|
110
|
+
root.set("title", "AB")
|
|
111
|
+
const v2 = new YjsVersion(Y.encodeStateVector(doc))
|
|
112
|
+
|
|
113
|
+
expect(v1.compare(v2)).toBe("behind")
|
|
114
|
+
expect(v2.compare(v1)).toBe("ahead")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("returns 'behind' / 'ahead' across multiple mutations", () => {
|
|
118
|
+
const doc = new Y.Doc()
|
|
119
|
+
const root = doc.getMap("root")
|
|
120
|
+
root.set("t", "Hello")
|
|
121
|
+
const early = new YjsVersion(Y.encodeStateVector(doc))
|
|
122
|
+
|
|
123
|
+
root.set("c", 5)
|
|
124
|
+
const items = new Y.Array()
|
|
125
|
+
items.insert(0, ["x"])
|
|
126
|
+
root.set("items", items)
|
|
127
|
+
const late = new YjsVersion(Y.encodeStateVector(doc))
|
|
128
|
+
|
|
129
|
+
expect(early.compare(late)).toBe("behind")
|
|
130
|
+
expect(late.compare(early)).toBe("ahead")
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("returns 'concurrent' for divergent versions (two independent peers)", () => {
|
|
134
|
+
const doc1 = new Y.Doc()
|
|
135
|
+
const doc2 = new Y.Doc()
|
|
136
|
+
|
|
137
|
+
doc1.getMap("root").set("title", "From peer 1")
|
|
138
|
+
doc2.getMap("root").set("title", "From peer 2")
|
|
139
|
+
|
|
140
|
+
const v1 = new YjsVersion(Y.encodeStateVector(doc1))
|
|
141
|
+
const v2 = new YjsVersion(Y.encodeStateVector(doc2))
|
|
142
|
+
|
|
143
|
+
expect(v1.compare(v2)).toBe("concurrent")
|
|
144
|
+
expect(v2.compare(v1)).toBe("concurrent")
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("returns 'behind' after syncing one direction only", () => {
|
|
148
|
+
const doc1 = new Y.Doc()
|
|
149
|
+
const doc2 = new Y.Doc()
|
|
150
|
+
|
|
151
|
+
doc1.getMap("root").set("t", "A")
|
|
152
|
+
doc2.getMap("root").set("t", "B")
|
|
153
|
+
|
|
154
|
+
// Sync doc1 → doc2 only (doc2 knows about both, doc1 only knows itself)
|
|
155
|
+
const update = Y.encodeStateAsUpdate(
|
|
156
|
+
doc1,
|
|
157
|
+
Y.encodeStateVector(doc2),
|
|
158
|
+
)
|
|
159
|
+
Y.applyUpdate(doc2, update)
|
|
160
|
+
|
|
161
|
+
const v1 = new YjsVersion(Y.encodeStateVector(doc1))
|
|
162
|
+
const v2 = new YjsVersion(Y.encodeStateVector(doc2))
|
|
163
|
+
|
|
164
|
+
// doc1 is behind (doesn't know about doc2's ops)
|
|
165
|
+
// doc2 is ahead (knows about both)
|
|
166
|
+
expect(v1.compare(v2)).toBe("behind")
|
|
167
|
+
expect(v2.compare(v1)).toBe("ahead")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("returns 'equal' after bidirectional sync", () => {
|
|
171
|
+
const doc1 = new Y.Doc()
|
|
172
|
+
const doc2 = new Y.Doc()
|
|
173
|
+
|
|
174
|
+
doc1.getMap("root").set("t", "A")
|
|
175
|
+
doc2.getMap("root").set("t", "B")
|
|
176
|
+
|
|
177
|
+
// Bidirectional sync
|
|
178
|
+
const u1to2 = Y.encodeStateAsUpdate(
|
|
179
|
+
doc1,
|
|
180
|
+
Y.encodeStateVector(doc2),
|
|
181
|
+
)
|
|
182
|
+
const u2to1 = Y.encodeStateAsUpdate(
|
|
183
|
+
doc2,
|
|
184
|
+
Y.encodeStateVector(doc1),
|
|
185
|
+
)
|
|
186
|
+
Y.applyUpdate(doc2, u1to2)
|
|
187
|
+
Y.applyUpdate(doc1, u2to1)
|
|
188
|
+
|
|
189
|
+
const v1 = new YjsVersion(Y.encodeStateVector(doc1))
|
|
190
|
+
const v2 = new YjsVersion(Y.encodeStateVector(doc2))
|
|
191
|
+
expect(v1.compare(v2)).toBe("equal")
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("throws when comparing with a non-YjsVersion", () => {
|
|
195
|
+
const v = emptyVersion()
|
|
196
|
+
const fake = {
|
|
197
|
+
serialize: () => "fake",
|
|
198
|
+
compare: () => "equal" as const,
|
|
199
|
+
}
|
|
200
|
+
expect(() => v.compare(fake)).toThrow(
|
|
201
|
+
"YjsVersion can only be compared with another YjsVersion",
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------------------
|
|
207
|
+
// compare after round-trip
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
describe("compare after serialize/parse", () => {
|
|
211
|
+
it("parsed version compares correctly with advanced version", () => {
|
|
212
|
+
const doc = new Y.Doc()
|
|
213
|
+
const root = doc.getMap("root")
|
|
214
|
+
|
|
215
|
+
root.set("t", "Hello")
|
|
216
|
+
const early = new YjsVersion(Y.encodeStateVector(doc))
|
|
217
|
+
const earlySerialized = early.serialize()
|
|
218
|
+
|
|
219
|
+
root.set("t", "Hello World")
|
|
220
|
+
const late = new YjsVersion(Y.encodeStateVector(doc))
|
|
221
|
+
|
|
222
|
+
const earlyParsed = YjsVersion.parse(earlySerialized)
|
|
223
|
+
expect(earlyParsed.compare(late)).toBe("behind")
|
|
224
|
+
expect(late.compare(earlyParsed)).toBe("ahead")
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
})
|
package/src/bind-yjs.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// bind-yjs — bindYjs() convenience wrapper for Yjs CRDT substrate.
|
|
2
|
+
//
|
|
3
|
+
// Binds a schema to the Yjs substrate with causal merge strategy.
|
|
4
|
+
// The factory builder accepts { peerId } and returns a SubstrateFactory
|
|
5
|
+
// that sets doc.clientID on every new Y.Doc, ensuring deterministic
|
|
6
|
+
// peer identity across all documents in an exchange.
|
|
7
|
+
//
|
|
8
|
+
// Yjs clientID is a uint32 number. We use FNV-1a hash truncated to
|
|
9
|
+
// 32 bits, mirroring the Loro binding's hashPeerId pattern but
|
|
10
|
+
// targeting Yjs's number type (not Loro's bigint/53-bit PeerID).
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// import { bindYjs } from "@kyneta/yjs-schema"
|
|
14
|
+
//
|
|
15
|
+
// const TodoDoc = bindYjs(Schema.doc({
|
|
16
|
+
// title: Schema.annotated("text"),
|
|
17
|
+
// items: Schema.list(Schema.struct({ name: Schema.string() })),
|
|
18
|
+
// }))
|
|
19
|
+
//
|
|
20
|
+
// const doc = exchange.get("my-doc", TodoDoc)
|
|
21
|
+
|
|
22
|
+
import { bind } from "@kyneta/schema"
|
|
23
|
+
import type { BoundSchema } from "@kyneta/schema"
|
|
24
|
+
import type { Schema as SchemaNode } from "@kyneta/schema"
|
|
25
|
+
import type {
|
|
26
|
+
Substrate,
|
|
27
|
+
SubstrateFactory,
|
|
28
|
+
SubstratePayload,
|
|
29
|
+
} from "@kyneta/schema"
|
|
30
|
+
import * as Y from "yjs"
|
|
31
|
+
import { createYjsSubstrate } from "./substrate.js"
|
|
32
|
+
import { YjsVersion } from "./version.js"
|
|
33
|
+
import { ensureContainers } from "./populate.js"
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Peer ID hashing — deterministic string → numeric Yjs clientID
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hash a string peerId to a deterministic numeric Yjs clientID.
|
|
41
|
+
*
|
|
42
|
+
* Yjs clientIDs are unsigned 32-bit integers. We use FNV-1a hash to
|
|
43
|
+
* produce a deterministic uint32 from the string peerId.
|
|
44
|
+
*
|
|
45
|
+
* The hash is deterministic: the same string always produces the same
|
|
46
|
+
* numeric clientID, across restarts and across machines.
|
|
47
|
+
*/
|
|
48
|
+
function hashPeerId(peerId: string): number {
|
|
49
|
+
// FNV-1a 32-bit hash
|
|
50
|
+
let hash = 0x811c9dc5
|
|
51
|
+
for (let i = 0; i < peerId.length; i++) {
|
|
52
|
+
hash ^= peerId.charCodeAt(i)
|
|
53
|
+
// Multiply by FNV prime 0x01000193.
|
|
54
|
+
// Use Math.imul for correct 32-bit integer multiplication.
|
|
55
|
+
hash = Math.imul(hash, 0x01000193)
|
|
56
|
+
}
|
|
57
|
+
// Ensure unsigned 32-bit integer
|
|
58
|
+
return hash >>> 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// createYjsFactory — factory builder with peer identity injection
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a SubstrateFactory<YjsVersion> that sets doc.clientID
|
|
67
|
+
* on every new Y.Doc with a deterministic uint32 clientID derived
|
|
68
|
+
* from the exchange's string peerId.
|
|
69
|
+
*/
|
|
70
|
+
function createYjsFactory(
|
|
71
|
+
peerId: string,
|
|
72
|
+
): SubstrateFactory<YjsVersion> {
|
|
73
|
+
const numericClientId = hashPeerId(peerId)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
77
|
+
const doc = new Y.Doc()
|
|
78
|
+
doc.clientID = numericClientId
|
|
79
|
+
|
|
80
|
+
ensureContainers(doc, schema)
|
|
81
|
+
return createYjsSubstrate(doc, schema)
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
fromSnapshot(
|
|
85
|
+
payload: SubstratePayload,
|
|
86
|
+
schema: SchemaNode,
|
|
87
|
+
): Substrate<YjsVersion> {
|
|
88
|
+
if (
|
|
89
|
+
payload.encoding !== "binary" ||
|
|
90
|
+
!(payload.data instanceof Uint8Array)
|
|
91
|
+
) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
"YjsSubstrateFactory.fromSnapshot only supports binary-encoded payloads",
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
const doc = new Y.Doc()
|
|
97
|
+
doc.clientID = numericClientId
|
|
98
|
+
Y.applyUpdate(doc, payload.data)
|
|
99
|
+
return createYjsSubstrate(doc, schema)
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
parseVersion(serialized: string): YjsVersion {
|
|
103
|
+
return YjsVersion.parse(serialized)
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// bindYjs — the convenience wrapper
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Bind a schema to the Yjs CRDT substrate with causal merge strategy.
|
|
114
|
+
*
|
|
115
|
+
* This is the recommended way to declare a Yjs-backed document type.
|
|
116
|
+
* The factory builder injects a deterministic numeric Yjs clientID derived
|
|
117
|
+
* from the exchange's string peerId, ensuring consistent change attribution
|
|
118
|
+
* across all documents and sessions.
|
|
119
|
+
*
|
|
120
|
+
* **Unsupported annotations:** Yjs has no native counter, movable list,
|
|
121
|
+
* or tree types. Schemas passed to `bindYjs` must not contain
|
|
122
|
+
* `Schema.annotated("counter")`, `Schema.annotated("movable")`, or
|
|
123
|
+
* `Schema.annotated("tree")`. These will throw at construction time.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { bindYjs } from "@kyneta/yjs-schema"
|
|
128
|
+
* import { Schema } from "@kyneta/schema"
|
|
129
|
+
*
|
|
130
|
+
* const TodoDoc = bindYjs(Schema.doc({
|
|
131
|
+
* title: Schema.annotated("text"),
|
|
132
|
+
* items: Schema.list(Schema.struct({
|
|
133
|
+
* name: Schema.string(),
|
|
134
|
+
* done: Schema.boolean(),
|
|
135
|
+
* })),
|
|
136
|
+
* }))
|
|
137
|
+
*
|
|
138
|
+
* const doc = exchange.get("my-todos", TodoDoc)
|
|
139
|
+
* ```
|
|
140
|
+
*/
|
|
141
|
+
export function bindYjs<S extends SchemaNode>(schema: S): BoundSchema<S> {
|
|
142
|
+
return bind({
|
|
143
|
+
schema,
|
|
144
|
+
factory: (ctx) => createYjsFactory(ctx.peerId),
|
|
145
|
+
strategy: "causal",
|
|
146
|
+
})
|
|
147
|
+
}
|