@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.
@@ -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
+ })
@@ -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
+ }