@kyneta/yjs-schema 1.0.0 → 1.2.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/dist/index.d.ts +109 -147
- package/dist/index.js +321 -210
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/bind-constraints.test.ts +333 -0
- package/src/__tests__/bind-yjs.test.ts +53 -55
- package/src/__tests__/create.test.ts +71 -62
- package/src/__tests__/{store-reader.test.ts → reader.test.ts} +64 -90
- package/src/__tests__/record-text-spike.test.ts +38 -31
- package/src/__tests__/structural-merge.test.ts +362 -0
- package/src/__tests__/substrate.test.ts +65 -84
- package/src/__tests__/version.test.ts +82 -16
- package/src/bind-yjs.ts +115 -64
- package/src/change-mapping.ts +60 -84
- package/src/create.ts +33 -28
- package/src/index.ts +32 -51
- package/src/populate.ts +87 -92
- package/src/{store-reader.ts → reader.ts} +7 -12
- package/src/substrate.ts +186 -42
- package/src/sync.ts +26 -26
- package/src/version.ts +57 -4
- package/src/yjs-resolve.ts +5 -21
- package/src/yjs-escape.ts +0 -100
package/src/substrate.ts
CHANGED
|
@@ -8,25 +8,26 @@
|
|
|
8
8
|
// The event bridge contract: wrapping a Y.Doc in a kyneta substrate
|
|
9
9
|
// means subscribing to the kyneta doc observes ALL mutations to the
|
|
10
10
|
// underlying Y.Doc, regardless of source (local kyneta writes,
|
|
11
|
-
//
|
|
11
|
+
// merge, external Y.applyUpdate, external raw Yjs API mutations).
|
|
12
12
|
|
|
13
13
|
import type {
|
|
14
14
|
ChangeBase,
|
|
15
15
|
Path,
|
|
16
|
+
Reader,
|
|
17
|
+
Replica,
|
|
18
|
+
ReplicaFactory,
|
|
16
19
|
Schema as SchemaNode,
|
|
17
|
-
StoreReader,
|
|
18
20
|
Substrate,
|
|
19
21
|
SubstrateFactory,
|
|
20
22
|
SubstratePayload,
|
|
21
23
|
WritableContext,
|
|
22
24
|
} from "@kyneta/schema"
|
|
23
|
-
import { buildWritableContext, executeBatch } from "@kyneta/schema"
|
|
25
|
+
import { BACKING_DOC, buildWritableContext, executeBatch } from "@kyneta/schema"
|
|
24
26
|
import * as Y from "yjs"
|
|
25
27
|
import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
26
28
|
import { ensureContainers } from "./populate.js"
|
|
27
|
-
import {
|
|
29
|
+
import { yjsReader } from "./reader.js"
|
|
28
30
|
import { YjsVersion } from "./version.js"
|
|
29
|
-
import { registerYjsSubstrate } from "./yjs-escape.js"
|
|
30
31
|
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
// Origin tag — used to suppress echo from our own transactions
|
|
@@ -44,11 +45,11 @@ const KYNETA_ORIGIN = "kyneta-prepare"
|
|
|
44
45
|
* This is the "bring your own doc" entry point. The user creates and
|
|
45
46
|
* manages the Y.Doc (possibly via a Yjs provider); this function wraps
|
|
46
47
|
* it with a schema-aware overlay providing typed reads, writes,
|
|
47
|
-
* versioning, and export/
|
|
48
|
+
* versioning, and export/merge through the standard Substrate interface.
|
|
48
49
|
*
|
|
49
50
|
* **Event bridge contract:** A persistent `observeDeep` handler is
|
|
50
51
|
* registered on the root Y.Map at construction time. All non-kyneta
|
|
51
|
-
* mutations to the Y.Doc (
|
|
52
|
+
* mutations to the Y.Doc (merges, external local writes) are bridged
|
|
52
53
|
* to the kyneta changefeed. Subscribing to the kyneta doc observes all
|
|
53
54
|
* mutations regardless of source.
|
|
54
55
|
*
|
|
@@ -71,8 +72,8 @@ export function createYjsSubstrate(
|
|
|
71
72
|
// to be), and onFlush() skips transact/commit.
|
|
72
73
|
let inOurTransaction = false
|
|
73
74
|
|
|
74
|
-
// Stashed origin from
|
|
75
|
-
let
|
|
75
|
+
// Stashed origin from merge for the event bridge to pick up.
|
|
76
|
+
let pendingMergeOrigin: string | undefined
|
|
76
77
|
|
|
77
78
|
// Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
|
|
78
79
|
let cachedCtx: WritableContext | undefined
|
|
@@ -80,13 +81,15 @@ export function createYjsSubstrate(
|
|
|
80
81
|
// The root Y.Map — all schema fields are children of this single map.
|
|
81
82
|
const rootMap = doc.getMap("root")
|
|
82
83
|
|
|
83
|
-
// The
|
|
84
|
-
const reader:
|
|
84
|
+
// The Reader — live view over the Yjs shared type tree.
|
|
85
|
+
const reader: Reader = yjsReader(doc, schema)
|
|
85
86
|
|
|
86
87
|
// --- Substrate object ---
|
|
87
88
|
|
|
88
|
-
const substrate
|
|
89
|
-
|
|
89
|
+
const substrate = {
|
|
90
|
+
[BACKING_DOC]: doc,
|
|
91
|
+
|
|
92
|
+
reader: reader,
|
|
90
93
|
|
|
91
94
|
prepare(path: Path, change: ChangeBase): void {
|
|
92
95
|
if (!inOurTransaction) {
|
|
@@ -98,7 +101,7 @@ export function createYjsSubstrate(
|
|
|
98
101
|
// wrappedPrepare (changefeed layer) still buffers the op.
|
|
99
102
|
},
|
|
100
103
|
|
|
101
|
-
onFlush(
|
|
104
|
+
onFlush(_origin?: string): void {
|
|
102
105
|
if (!inOurTransaction && pendingChanges.length > 0) {
|
|
103
106
|
// Local write: apply accumulated changes within a single
|
|
104
107
|
// Yjs transaction tagged with our origin for echo suppression.
|
|
@@ -129,8 +132,21 @@ export function createYjsSubstrate(
|
|
|
129
132
|
return new YjsVersion(Y.encodeStateVector(doc))
|
|
130
133
|
},
|
|
131
134
|
|
|
132
|
-
|
|
135
|
+
baseVersion(): YjsVersion {
|
|
136
|
+
// Yjs substrate: base is always the initial state (no advance supported).
|
|
137
|
+
return new YjsVersion(new Uint8Array([0]))
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
advance(_to: YjsVersion): void {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"advance() on a live Yjs substrate is not yet supported. " +
|
|
143
|
+
"Use advance() on a YjsReplica instead.",
|
|
144
|
+
)
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
exportEntirety(): SubstratePayload {
|
|
133
148
|
return {
|
|
149
|
+
kind: "entirety",
|
|
134
150
|
encoding: "binary",
|
|
135
151
|
data: Y.encodeStateAsUpdate(doc),
|
|
136
152
|
}
|
|
@@ -139,27 +155,28 @@ export function createYjsSubstrate(
|
|
|
139
155
|
exportSince(since: YjsVersion): SubstratePayload | null {
|
|
140
156
|
try {
|
|
141
157
|
const bytes = Y.encodeStateAsUpdate(doc, since.sv)
|
|
142
|
-
return { encoding: "binary", data: bytes }
|
|
158
|
+
return { kind: "since", encoding: "binary", data: bytes }
|
|
143
159
|
} catch {
|
|
144
160
|
return null
|
|
145
161
|
}
|
|
146
162
|
},
|
|
147
163
|
|
|
148
|
-
|
|
164
|
+
merge(payload: SubstratePayload, origin?: string): void {
|
|
149
165
|
if (
|
|
150
166
|
payload.encoding !== "binary" ||
|
|
151
167
|
!(payload.data instanceof Uint8Array)
|
|
152
168
|
) {
|
|
153
169
|
throw new Error(
|
|
154
|
-
"YjsSubstrate.
|
|
170
|
+
"YjsSubstrate.merge expects binary-encoded payloads. " +
|
|
171
|
+
"If you recently switched CRDT backends, stale clients may be sending incompatible data.",
|
|
155
172
|
)
|
|
156
173
|
}
|
|
157
174
|
// Stash origin for the event bridge to pick up
|
|
158
|
-
|
|
175
|
+
pendingMergeOrigin = origin
|
|
159
176
|
try {
|
|
160
177
|
Y.applyUpdate(doc, payload.data, origin ?? "remote")
|
|
161
178
|
} finally {
|
|
162
|
-
|
|
179
|
+
pendingMergeOrigin = undefined
|
|
163
180
|
}
|
|
164
181
|
// That's it — the observeDeep handler bridges events to the
|
|
165
182
|
// changefeed via executeBatch.
|
|
@@ -175,18 +192,16 @@ export function createYjsSubstrate(
|
|
|
175
192
|
}
|
|
176
193
|
|
|
177
194
|
// Convert Yjs events → kyneta Ops
|
|
178
|
-
const ops = eventsToOps(events)
|
|
195
|
+
const ops = eventsToOps(events, schema)
|
|
179
196
|
if (ops.length === 0) {
|
|
180
197
|
return
|
|
181
198
|
}
|
|
182
199
|
|
|
183
|
-
// Determine origin: prefer stashed kyneta origin (from
|
|
200
|
+
// Determine origin: prefer stashed kyneta origin (from merge),
|
|
184
201
|
// fall back to the transaction's origin if it's a string.
|
|
185
202
|
const origin =
|
|
186
|
-
|
|
187
|
-
(typeof transaction.origin === "string"
|
|
188
|
-
? transaction.origin
|
|
189
|
-
: undefined)
|
|
203
|
+
pendingMergeOrigin ??
|
|
204
|
+
(typeof transaction.origin === "string" ? transaction.origin : undefined)
|
|
190
205
|
|
|
191
206
|
// Lazily ensure the context is built
|
|
192
207
|
const ctx = substrate.context()
|
|
@@ -202,10 +217,7 @@ export function createYjsSubstrate(
|
|
|
202
217
|
}
|
|
203
218
|
})
|
|
204
219
|
|
|
205
|
-
|
|
206
|
-
registerYjsSubstrate(substrate, doc)
|
|
207
|
-
|
|
208
|
-
return substrate
|
|
220
|
+
return substrate as Substrate<YjsVersion>
|
|
209
221
|
}
|
|
210
222
|
|
|
211
223
|
// ---------------------------------------------------------------------------
|
|
@@ -218,35 +230,167 @@ export function createYjsSubstrate(
|
|
|
218
230
|
* - `create(schema)` — creates a fresh Y.Doc with empty containers
|
|
219
231
|
* matching the schema structure. No seed data — initial content
|
|
220
232
|
* should be applied via `change()` after construction.
|
|
221
|
-
* - `
|
|
233
|
+
* - `fromEntirety(payload, schema)` — creates a Y.Doc from an entirety
|
|
222
234
|
* payload, returns a substrate.
|
|
223
235
|
* - `parseVersion(serialized)` — deserializes a YjsVersion.
|
|
224
236
|
*/
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// yjsReplicaFactory — ReplicaFactory<YjsVersion>
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Schema-free replica factory for Yjs substrates.
|
|
243
|
+
*
|
|
244
|
+
* Constructs headless `Replica<YjsVersion>` instances backed by bare
|
|
245
|
+
* `Y.Doc`s — no schema walking, no container initialization, no
|
|
246
|
+
* Reader, no event bridge, no changefeed. Just the CRDT runtime
|
|
247
|
+
* with version tracking and export/merge.
|
|
248
|
+
*
|
|
249
|
+
* Used by conduit participants (stores, routing servers)
|
|
250
|
+
* that need to accumulate state, compute per-peer deltas, and compact
|
|
251
|
+
* storage without ever interpreting document fields.
|
|
252
|
+
*/
|
|
253
|
+
export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
254
|
+
let currentDoc = doc
|
|
255
|
+
let currentBase: YjsVersion = new YjsVersion(
|
|
256
|
+
Y.encodeStateVector(new Y.Doc()),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
get [BACKING_DOC]() {
|
|
261
|
+
return currentDoc
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
version(): YjsVersion {
|
|
265
|
+
return new YjsVersion(Y.encodeStateVector(currentDoc))
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
baseVersion(): YjsVersion {
|
|
269
|
+
return currentBase
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
advance(to: YjsVersion): void {
|
|
273
|
+
const baseCmp = currentBase.compare(to)
|
|
274
|
+
if (baseCmp === "ahead") {
|
|
275
|
+
throw new Error("advance(): target is behind base version")
|
|
276
|
+
}
|
|
277
|
+
const currentCmp = to.compare(this.version())
|
|
278
|
+
if (currentCmp === "ahead") {
|
|
279
|
+
throw new Error("advance(): target is ahead of current version")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Yjs can only do full projection (to = version).
|
|
283
|
+
// For any to < version, it's a no-op — undershoot contract.
|
|
284
|
+
if (currentCmp !== "equal") return
|
|
285
|
+
|
|
286
|
+
// Full projection: create a new doc with current state, no history.
|
|
287
|
+
const update = Y.encodeStateAsUpdate(currentDoc)
|
|
288
|
+
const newDoc = new Y.Doc()
|
|
289
|
+
Y.applyUpdate(newDoc, update)
|
|
290
|
+
currentDoc = newDoc
|
|
291
|
+
currentBase = new YjsVersion(Y.encodeStateVector(currentDoc))
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
exportEntirety(): SubstratePayload {
|
|
295
|
+
return {
|
|
296
|
+
kind: "entirety",
|
|
297
|
+
encoding: "binary",
|
|
298
|
+
data: Y.encodeStateAsUpdate(currentDoc),
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
exportSince(since: YjsVersion): SubstratePayload | null {
|
|
303
|
+
try {
|
|
304
|
+
const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
|
|
305
|
+
return { kind: "since", encoding: "binary", data: bytes }
|
|
306
|
+
} catch {
|
|
307
|
+
return null
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
merge(payload: SubstratePayload, _origin?: string): void {
|
|
312
|
+
if (
|
|
313
|
+
payload.encoding !== "binary" ||
|
|
314
|
+
!(payload.data instanceof Uint8Array)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
"YjsReplica.merge expects binary-encoded payloads. " +
|
|
318
|
+
"If you recently switched CRDT backends, stale clients may be sending incompatible data.",
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
Y.applyUpdate(currentDoc, payload.data)
|
|
322
|
+
},
|
|
323
|
+
} as Replica<YjsVersion>
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export const yjsReplicaFactory: ReplicaFactory<YjsVersion> = {
|
|
327
|
+
replicaType: ["yjs", 1, 0] as const,
|
|
328
|
+
|
|
329
|
+
createEmpty(): Replica<YjsVersion> {
|
|
330
|
+
return createYjsReplica(new Y.Doc())
|
|
230
331
|
},
|
|
231
332
|
|
|
232
|
-
|
|
233
|
-
payload: SubstratePayload,
|
|
234
|
-
schema: SchemaNode,
|
|
235
|
-
): Substrate<YjsVersion> {
|
|
333
|
+
fromEntirety(payload: SubstratePayload): Replica<YjsVersion> {
|
|
236
334
|
if (
|
|
237
335
|
payload.encoding !== "binary" ||
|
|
238
336
|
!(payload.data instanceof Uint8Array)
|
|
239
337
|
) {
|
|
240
338
|
throw new Error(
|
|
241
|
-
"
|
|
339
|
+
"YjsReplicaFactory.fromEntirety only supports binary-encoded payloads",
|
|
242
340
|
)
|
|
243
341
|
}
|
|
244
342
|
const doc = new Y.Doc()
|
|
245
343
|
Y.applyUpdate(doc, payload.data)
|
|
344
|
+
return createYjsReplica(doc)
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
parseVersion(serialized: string): YjsVersion {
|
|
348
|
+
return YjsVersion.parse(serialized)
|
|
349
|
+
},
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// yjsSubstrateFactory — SubstrateFactory<YjsVersion>
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
|
|
357
|
+
replica: yjsReplicaFactory,
|
|
358
|
+
|
|
359
|
+
createReplica(): Replica<YjsVersion> {
|
|
360
|
+
// Default random clientID — safe for hydration (no local writes).
|
|
361
|
+
return createYjsReplica(new Y.Doc())
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
upgrade(
|
|
365
|
+
replica: Replica<YjsVersion>,
|
|
366
|
+
schema: SchemaNode,
|
|
367
|
+
): Substrate<YjsVersion> {
|
|
368
|
+
const doc = (replica as any)[BACKING_DOC] as Y.Doc
|
|
369
|
+
// No identity injection for the standalone factory (no peerId).
|
|
370
|
+
// Conditional ensureContainers: skip fields that already exist
|
|
371
|
+
// from hydrated state.
|
|
372
|
+
ensureContainers(doc, schema, true)
|
|
373
|
+
return createYjsSubstrate(doc, schema)
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
377
|
+
// Fresh doc — unconditional ensureContainers (nothing to conflict with).
|
|
378
|
+
const doc = new Y.Doc()
|
|
379
|
+
ensureContainers(doc, schema)
|
|
246
380
|
return createYjsSubstrate(doc, schema)
|
|
247
381
|
},
|
|
248
382
|
|
|
383
|
+
fromEntirety(
|
|
384
|
+
payload: SubstratePayload,
|
|
385
|
+
schema: SchemaNode,
|
|
386
|
+
): Substrate<YjsVersion> {
|
|
387
|
+
// Two-phase path: createReplica → merge → upgrade
|
|
388
|
+
const replica = this.createReplica()
|
|
389
|
+
replica.merge(payload)
|
|
390
|
+
return this.upgrade(replica, schema)
|
|
391
|
+
},
|
|
392
|
+
|
|
249
393
|
parseVersion(serialized: string): YjsVersion {
|
|
250
394
|
return YjsVersion.parse(serialized)
|
|
251
395
|
},
|
|
252
|
-
}
|
|
396
|
+
}
|
package/src/sync.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// sync — sync primitives for YjsSubstrate-backed documents.
|
|
2
2
|
//
|
|
3
|
-
// These functions provide version tracking,
|
|
4
|
-
//
|
|
5
|
-
// `
|
|
3
|
+
// These functions provide version tracking, entirety export, and merge
|
|
4
|
+
// for documents created via `createYjsDoc` or
|
|
5
|
+
// `createYjsDocFromEntirety`. They discover the substrate via the
|
|
6
6
|
// module-scoped WeakMap in `create.ts`.
|
|
7
7
|
//
|
|
8
8
|
// Unlike PlainSubstrate's sync (which returns Op[] for deltas),
|
|
9
|
-
// YjsSubstrate's sync uses binary SubstratePayload for both
|
|
9
|
+
// YjsSubstrate's sync uses binary SubstratePayload for both entireties
|
|
10
10
|
// and deltas — these are Yjs's native state-as-update bytes.
|
|
11
11
|
|
|
12
12
|
import type { SubstratePayload } from "@kyneta/schema"
|
|
13
|
-
import { YjsVersion } from "./version.js"
|
|
14
13
|
import { getSubstrate } from "./create.js"
|
|
14
|
+
import type { YjsVersion } from "./version.js"
|
|
15
15
|
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
// version — current YjsVersion
|
|
@@ -23,28 +23,28 @@ import { getSubstrate } from "./create.js"
|
|
|
23
23
|
* Use `.serialize()` to get a text-safe string for embedding in HTML
|
|
24
24
|
* meta tags, URL parameters, etc.
|
|
25
25
|
*
|
|
26
|
-
* @param doc - A document created by `createYjsDoc` or `
|
|
27
|
-
* @throws If `doc` was not created by `createYjsDoc` / `
|
|
26
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
27
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
28
28
|
*/
|
|
29
29
|
export function version(doc: object): YjsVersion {
|
|
30
30
|
return getSubstrate(doc).version()
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
|
-
//
|
|
34
|
+
// exportEntirety — full state for reconstruction
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Export the full substrate
|
|
39
|
-
* reconstruct an equivalent document via `
|
|
38
|
+
* Export the full substrate entirety — sufficient for a new peer to
|
|
39
|
+
* reconstruct an equivalent document via `createYjsDocFromEntirety()`.
|
|
40
40
|
*
|
|
41
41
|
* Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
|
|
42
42
|
*
|
|
43
|
-
* @param doc - A document created by `createYjsDoc` or `
|
|
44
|
-
* @throws If `doc` was not created by `createYjsDoc` / `
|
|
43
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
44
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
45
45
|
*/
|
|
46
|
-
export function
|
|
47
|
-
return getSubstrate(doc).
|
|
46
|
+
export function exportEntirety(doc: object): SubstratePayload {
|
|
47
|
+
return getSubstrate(doc).exportEntirety()
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// ---------------------------------------------------------------------------
|
|
@@ -61,12 +61,12 @@ export function exportSnapshot(doc: object): SubstratePayload {
|
|
|
61
61
|
* const v0 = version(docA)
|
|
62
62
|
* change(docA, d => d.title.insert(0, "Hi"))
|
|
63
63
|
* const delta = exportSince(docA, v0)
|
|
64
|
-
*
|
|
64
|
+
* merge(docB, delta!)
|
|
65
65
|
* ```
|
|
66
66
|
*
|
|
67
|
-
* @param doc - A document created by `createYjsDoc` or `
|
|
67
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
68
68
|
* @param since - The version to diff from.
|
|
69
|
-
* @throws If `doc` was not created by `createYjsDoc` / `
|
|
69
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
70
70
|
*/
|
|
71
71
|
export function exportSince(
|
|
72
72
|
doc: object,
|
|
@@ -76,32 +76,32 @@ export function exportSince(
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// ---------------------------------------------------------------------------
|
|
79
|
-
//
|
|
79
|
+
// merge — apply a delta from another peer
|
|
80
80
|
// ---------------------------------------------------------------------------
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
83
|
* Import a delta payload into a live document.
|
|
84
84
|
*
|
|
85
85
|
* The payload must have been produced by `exportSince()` or
|
|
86
|
-
* `
|
|
86
|
+
* `exportEntirety()` on a compatible document.
|
|
87
87
|
*
|
|
88
88
|
* After import, the changefeed fires for all subscribers — the event
|
|
89
89
|
* bridge handles this automatically.
|
|
90
90
|
*
|
|
91
91
|
* ```ts
|
|
92
92
|
* const delta = exportSince(docA, sinceVersion)
|
|
93
|
-
*
|
|
93
|
+
* merge(docB, delta!, "sync")
|
|
94
94
|
* ```
|
|
95
95
|
*
|
|
96
|
-
* @param doc - A document created by `createYjsDoc` or `
|
|
97
|
-
* @param payload - The delta or
|
|
96
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
97
|
+
* @param payload - The delta or entirety payload to merge.
|
|
98
98
|
* @param origin - Optional provenance tag for the changeset.
|
|
99
|
-
* @throws If `doc` was not created by `createYjsDoc` / `
|
|
99
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
100
100
|
*/
|
|
101
|
-
export function
|
|
101
|
+
export function merge(
|
|
102
102
|
doc: object,
|
|
103
103
|
payload: SubstratePayload,
|
|
104
104
|
origin?: string,
|
|
105
105
|
): void {
|
|
106
|
-
getSubstrate(doc).
|
|
107
|
-
}
|
|
106
|
+
getSubstrate(doc).merge(payload, origin)
|
|
107
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// decoded `Map<number, number>` (clientID → clock) maps ourselves.
|
|
13
13
|
|
|
14
14
|
import type { Version } from "@kyneta/schema"
|
|
15
|
+
import { versionVectorMeet } from "@kyneta/schema"
|
|
15
16
|
import { decodeStateVector } from "yjs"
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
@@ -35,6 +36,39 @@ function base64ToUint8Array(base64: string): Uint8Array {
|
|
|
35
36
|
return bytes
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// State vector encoding — manual varint (unsigned LEB128)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Encode a state vector map to Yjs's binary state vector format.
|
|
45
|
+
*
|
|
46
|
+
* Yjs does not export `encodeStateVector(map)` — only `Y.encodeStateVector(doc)`
|
|
47
|
+
* which requires a full doc. This implements the same binary format directly:
|
|
48
|
+
* `[entryCount: varint, (clientId: varint, clock: varint)*]`
|
|
49
|
+
*
|
|
50
|
+
* Each value is encoded as an unsigned LEB128 varint.
|
|
51
|
+
*/
|
|
52
|
+
function encodeStateVector(map: Map<number, number>): Uint8Array {
|
|
53
|
+
const bytes: number[] = []
|
|
54
|
+
|
|
55
|
+
function writeVarUint(value: number): void {
|
|
56
|
+
while (value > 0x7f) {
|
|
57
|
+
bytes.push((value & 0x7f) | 0x80)
|
|
58
|
+
value >>>= 7
|
|
59
|
+
}
|
|
60
|
+
bytes.push(value & 0x7f)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
writeVarUint(map.size)
|
|
64
|
+
for (const [clientId, clock] of map) {
|
|
65
|
+
writeVarUint(clientId)
|
|
66
|
+
writeVarUint(clock)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Uint8Array(bytes)
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
// ---------------------------------------------------------------------------
|
|
39
73
|
// YjsVersion
|
|
40
74
|
// ---------------------------------------------------------------------------
|
|
@@ -85,9 +119,7 @@ export class YjsVersion implements Version {
|
|
|
85
119
|
*/
|
|
86
120
|
compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
|
|
87
121
|
if (!(other instanceof YjsVersion)) {
|
|
88
|
-
throw new Error(
|
|
89
|
-
"YjsVersion can only be compared with another YjsVersion",
|
|
90
|
-
)
|
|
122
|
+
throw new Error("YjsVersion can only be compared with another YjsVersion")
|
|
91
123
|
}
|
|
92
124
|
|
|
93
125
|
const thisMap = decodeStateVector(this.sv)
|
|
@@ -123,6 +155,27 @@ export class YjsVersion implements Version {
|
|
|
123
155
|
return "equal"
|
|
124
156
|
}
|
|
125
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Greatest lower bound (lattice meet) of two Yjs versions.
|
|
160
|
+
*
|
|
161
|
+
* Decodes both state vectors, computes the component-wise minimum
|
|
162
|
+
* via the shared `versionVectorMeet` utility, and encodes the result
|
|
163
|
+
* back to a Yjs state vector.
|
|
164
|
+
*
|
|
165
|
+
* @throws If `other` is not a `YjsVersion`.
|
|
166
|
+
*/
|
|
167
|
+
meet(other: Version): YjsVersion {
|
|
168
|
+
if (!(other instanceof YjsVersion)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
"YjsVersion can only be meet'd with another YjsVersion",
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
const thisMap = decodeStateVector(this.sv)
|
|
174
|
+
const otherMap = decodeStateVector(other.sv)
|
|
175
|
+
const result = versionVectorMeet(thisMap, otherMap)
|
|
176
|
+
return new YjsVersion(encodeStateVector(result))
|
|
177
|
+
}
|
|
178
|
+
|
|
126
179
|
/**
|
|
127
180
|
* Parse a serialized YjsVersion string back into a YjsVersion.
|
|
128
181
|
*
|
|
@@ -135,4 +188,4 @@ export class YjsVersion implements Version {
|
|
|
135
188
|
const bytes = base64ToUint8Array(serialized)
|
|
136
189
|
return new YjsVersion(bytes)
|
|
137
190
|
}
|
|
138
|
-
}
|
|
191
|
+
}
|
package/src/yjs-resolve.ts
CHANGED
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
// Using a single root Y.Map enables one `observeDeep` call that
|
|
15
15
|
// captures all mutations with correct relative paths.
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
19
|
-
import type { Schema as SchemaNode } from "@kyneta/schema"
|
|
17
|
+
import type { Path, Schema as SchemaNode, Segment } from "@kyneta/schema"
|
|
18
|
+
import { advanceSchema, KIND } from "@kyneta/schema"
|
|
20
19
|
import * as Y from "yjs"
|
|
21
20
|
|
|
22
21
|
// ---------------------------------------------------------------------------
|
|
@@ -35,10 +34,7 @@ import * as Y from "yjs"
|
|
|
35
34
|
* @param current - The current position (a Yjs shared type or plain value)
|
|
36
35
|
* @param segment - The path segment to follow
|
|
37
36
|
*/
|
|
38
|
-
export function stepIntoYjs(
|
|
39
|
-
current: unknown,
|
|
40
|
-
segment: Segment,
|
|
41
|
-
): unknown {
|
|
37
|
+
export function stepIntoYjs(current: unknown, segment: Segment): unknown {
|
|
42
38
|
const resolved = segment.resolve()
|
|
43
39
|
|
|
44
40
|
if (current instanceof Y.Map) {
|
|
@@ -50,9 +46,7 @@ export function stepIntoYjs(
|
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
if (current instanceof Y.Text) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`yjs-resolve: cannot step into Y.Text`,
|
|
55
|
-
)
|
|
49
|
+
throw new Error(`yjs-resolve: cannot step into Y.Text`)
|
|
56
50
|
}
|
|
57
51
|
|
|
58
52
|
// Plain value — terminal, cannot step further
|
|
@@ -84,16 +78,6 @@ export function resolveYjsType(
|
|
|
84
78
|
let current: unknown = rootMap
|
|
85
79
|
let schema = rootSchema
|
|
86
80
|
|
|
87
|
-
// Unwrap the root annotation (e.g. annotated("doc", product))
|
|
88
|
-
// to reach the product schema whose fields are the root map's children.
|
|
89
|
-
let rootProduct = rootSchema
|
|
90
|
-
while (
|
|
91
|
-
rootProduct._kind === "annotated" &&
|
|
92
|
-
rootProduct.schema !== undefined
|
|
93
|
-
) {
|
|
94
|
-
rootProduct = rootProduct.schema
|
|
95
|
-
}
|
|
96
|
-
|
|
97
81
|
for (let i = 0; i < path.length; i++) {
|
|
98
82
|
const seg = path.segments[i]!
|
|
99
83
|
const nextSchema = advanceSchema(schema, seg)
|
|
@@ -105,4 +89,4 @@ export function resolveYjsType(
|
|
|
105
89
|
}
|
|
106
90
|
|
|
107
91
|
return current
|
|
108
|
-
}
|
|
92
|
+
}
|