@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
package/src/substrate.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// substrate — YjsSubstrate implementation.
|
|
2
|
+
//
|
|
3
|
+
// Implements Substrate<YjsVersion> with:
|
|
4
|
+
// - Imperative local writes (prepare accumulates, onFlush applies in transact)
|
|
5
|
+
// - Persistent observeDeep event bridge for external changes
|
|
6
|
+
// - Single re-entrancy guard + transaction.origin check
|
|
7
|
+
//
|
|
8
|
+
// The event bridge contract: wrapping a Y.Doc in a kyneta substrate
|
|
9
|
+
// means subscribing to the kyneta doc observes ALL mutations to the
|
|
10
|
+
// underlying Y.Doc, regardless of source (local kyneta writes,
|
|
11
|
+
// importDelta, external Y.applyUpdate, external raw Yjs API mutations).
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ChangeBase,
|
|
15
|
+
Path,
|
|
16
|
+
Schema as SchemaNode,
|
|
17
|
+
StoreReader,
|
|
18
|
+
Substrate,
|
|
19
|
+
SubstrateFactory,
|
|
20
|
+
SubstratePayload,
|
|
21
|
+
WritableContext,
|
|
22
|
+
} from "@kyneta/schema"
|
|
23
|
+
import { buildWritableContext, executeBatch } from "@kyneta/schema"
|
|
24
|
+
import * as Y from "yjs"
|
|
25
|
+
import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
26
|
+
import { ensureContainers } from "./populate.js"
|
|
27
|
+
import { yjsStoreReader } from "./store-reader.js"
|
|
28
|
+
import { YjsVersion } from "./version.js"
|
|
29
|
+
import { registerYjsSubstrate } from "./yjs-escape.js"
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Origin tag — used to suppress echo from our own transactions
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const KYNETA_ORIGIN = "kyneta-prepare"
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// createYjsSubstrate — wrap a user-provided Y.Doc
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a `Substrate<YjsVersion>` wrapping a user-provided Y.Doc.
|
|
43
|
+
*
|
|
44
|
+
* This is the "bring your own doc" entry point. The user creates and
|
|
45
|
+
* manages the Y.Doc (possibly via a Yjs provider); this function wraps
|
|
46
|
+
* it with a schema-aware overlay providing typed reads, writes,
|
|
47
|
+
* versioning, and export/import through the standard Substrate interface.
|
|
48
|
+
*
|
|
49
|
+
* **Event bridge contract:** A persistent `observeDeep` handler is
|
|
50
|
+
* registered on the root Y.Map at construction time. All non-kyneta
|
|
51
|
+
* mutations to the Y.Doc (imports, external local writes) are bridged
|
|
52
|
+
* to the kyneta changefeed. Subscribing to the kyneta doc observes all
|
|
53
|
+
* mutations regardless of source.
|
|
54
|
+
*
|
|
55
|
+
* @param doc - The Y.Doc to wrap. The substrate does NOT own the doc;
|
|
56
|
+
* the caller is responsible for its lifecycle.
|
|
57
|
+
* @param schema - The root schema for the document.
|
|
58
|
+
*/
|
|
59
|
+
export function createYjsSubstrate(
|
|
60
|
+
doc: Y.Doc,
|
|
61
|
+
schema: SchemaNode,
|
|
62
|
+
): Substrate<YjsVersion> {
|
|
63
|
+
// --- Closure-scoped state ---
|
|
64
|
+
|
|
65
|
+
// Accumulated changes from prepare(), drained by onFlush().
|
|
66
|
+
const pendingChanges: Array<{ path: Path; change: ChangeBase }> = []
|
|
67
|
+
|
|
68
|
+
// Re-entrancy guard: set true around our doc.transact() in onFlush
|
|
69
|
+
// AND around executeBatch in the event bridge. When true, prepare()
|
|
70
|
+
// skips Yjs-side work (changes are already applied by Yjs or about
|
|
71
|
+
// to be), and onFlush() skips transact/commit.
|
|
72
|
+
let inOurTransaction = false
|
|
73
|
+
|
|
74
|
+
// Stashed origin from importDelta for the event bridge to pick up.
|
|
75
|
+
let pendingImportOrigin: string | undefined
|
|
76
|
+
|
|
77
|
+
// Lazy-built WritableContext (same pattern as PlainSubstrate / LoroSubstrate).
|
|
78
|
+
let cachedCtx: WritableContext | undefined
|
|
79
|
+
|
|
80
|
+
// The root Y.Map — all schema fields are children of this single map.
|
|
81
|
+
const rootMap = doc.getMap("root")
|
|
82
|
+
|
|
83
|
+
// The StoreReader — live view over the Yjs shared type tree.
|
|
84
|
+
const reader: StoreReader = yjsStoreReader(doc, schema)
|
|
85
|
+
|
|
86
|
+
// --- Substrate object ---
|
|
87
|
+
|
|
88
|
+
const substrate: Substrate<YjsVersion> = {
|
|
89
|
+
store: reader,
|
|
90
|
+
|
|
91
|
+
prepare(path: Path, change: ChangeBase): void {
|
|
92
|
+
if (!inOurTransaction) {
|
|
93
|
+
// Local write: accumulate for flush.
|
|
94
|
+
// No Yjs side effects — mutations happen at flush time.
|
|
95
|
+
pendingChanges.push({ path, change })
|
|
96
|
+
}
|
|
97
|
+
// During event handler replay: no-op on Yjs side.
|
|
98
|
+
// wrappedPrepare (changefeed layer) still buffers the op.
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
onFlush(origin?: string): void {
|
|
102
|
+
if (!inOurTransaction && pendingChanges.length > 0) {
|
|
103
|
+
// Local write: apply accumulated changes within a single
|
|
104
|
+
// Yjs transaction tagged with our origin for echo suppression.
|
|
105
|
+
inOurTransaction = true
|
|
106
|
+
try {
|
|
107
|
+
doc.transact(() => {
|
|
108
|
+
for (const { path, change } of pendingChanges) {
|
|
109
|
+
applyChangeToYjs(rootMap, schema, path, change)
|
|
110
|
+
}
|
|
111
|
+
}, KYNETA_ORIGIN)
|
|
112
|
+
pendingChanges.length = 0
|
|
113
|
+
} finally {
|
|
114
|
+
inOurTransaction = false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// During event handler replay: no-op on Yjs side.
|
|
118
|
+
// wrappedFlush (changefeed layer) still delivers notifications.
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
context(): WritableContext {
|
|
122
|
+
if (!cachedCtx) {
|
|
123
|
+
cachedCtx = buildWritableContext(substrate)
|
|
124
|
+
}
|
|
125
|
+
return cachedCtx
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
version(): YjsVersion {
|
|
129
|
+
return new YjsVersion(Y.encodeStateVector(doc))
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
exportSnapshot(): SubstratePayload {
|
|
133
|
+
return {
|
|
134
|
+
encoding: "binary",
|
|
135
|
+
data: Y.encodeStateAsUpdate(doc),
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
exportSince(since: YjsVersion): SubstratePayload | null {
|
|
140
|
+
try {
|
|
141
|
+
const bytes = Y.encodeStateAsUpdate(doc, since.sv)
|
|
142
|
+
return { encoding: "binary", data: bytes }
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
importDelta(payload: SubstratePayload, origin?: string): void {
|
|
149
|
+
if (
|
|
150
|
+
payload.encoding !== "binary" ||
|
|
151
|
+
!(payload.data instanceof Uint8Array)
|
|
152
|
+
) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
"YjsSubstrate.importDelta only supports binary-encoded payloads",
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
// Stash origin for the event bridge to pick up
|
|
158
|
+
pendingImportOrigin = origin
|
|
159
|
+
try {
|
|
160
|
+
Y.applyUpdate(doc, payload.data, origin ?? "remote")
|
|
161
|
+
} finally {
|
|
162
|
+
pendingImportOrigin = undefined
|
|
163
|
+
}
|
|
164
|
+
// That's it — the observeDeep handler bridges events to the
|
|
165
|
+
// changefeed via executeBatch.
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Event bridge (registered once at construction) ---
|
|
170
|
+
|
|
171
|
+
rootMap.observeDeep((events, transaction) => {
|
|
172
|
+
// Ignore our own transactions (changefeed already captured via wrappedPrepare)
|
|
173
|
+
if (transaction.origin === KYNETA_ORIGIN) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Convert Yjs events → kyneta Ops
|
|
178
|
+
const ops = eventsToOps(events)
|
|
179
|
+
if (ops.length === 0) {
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Determine origin: prefer stashed kyneta origin (from importDelta),
|
|
184
|
+
// fall back to the transaction's origin if it's a string.
|
|
185
|
+
const origin =
|
|
186
|
+
pendingImportOrigin ??
|
|
187
|
+
(typeof transaction.origin === "string"
|
|
188
|
+
? transaction.origin
|
|
189
|
+
: undefined)
|
|
190
|
+
|
|
191
|
+
// Lazily ensure the context is built
|
|
192
|
+
const ctx = substrate.context()
|
|
193
|
+
|
|
194
|
+
// Feed through executeBatch for changefeed delivery.
|
|
195
|
+
// The inOurTransaction guard prevents prepare/onFlush from doing
|
|
196
|
+
// Yjs-side work — the changes are already applied by Yjs.
|
|
197
|
+
inOurTransaction = true
|
|
198
|
+
try {
|
|
199
|
+
executeBatch(ctx, ops, origin)
|
|
200
|
+
} finally {
|
|
201
|
+
inOurTransaction = false
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Register for the yjs() escape hatch
|
|
206
|
+
registerYjsSubstrate(substrate, doc)
|
|
207
|
+
|
|
208
|
+
return substrate
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// yjsSubstrateFactory — SubstrateFactory<YjsVersion>
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Factory for constructing Yjs-backed substrates.
|
|
217
|
+
*
|
|
218
|
+
* - `create(schema)` — creates a fresh Y.Doc with empty containers
|
|
219
|
+
* matching the schema structure. No seed data — initial content
|
|
220
|
+
* should be applied via `change()` after construction.
|
|
221
|
+
* - `fromSnapshot(payload, schema)` — creates a Y.Doc from a snapshot
|
|
222
|
+
* payload, returns a substrate.
|
|
223
|
+
* - `parseVersion(serialized)` — deserializes a YjsVersion.
|
|
224
|
+
*/
|
|
225
|
+
export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
|
|
226
|
+
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
227
|
+
const doc = new Y.Doc()
|
|
228
|
+
ensureContainers(doc, schema)
|
|
229
|
+
return createYjsSubstrate(doc, schema)
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
fromSnapshot(
|
|
233
|
+
payload: SubstratePayload,
|
|
234
|
+
schema: SchemaNode,
|
|
235
|
+
): Substrate<YjsVersion> {
|
|
236
|
+
if (
|
|
237
|
+
payload.encoding !== "binary" ||
|
|
238
|
+
!(payload.data instanceof Uint8Array)
|
|
239
|
+
) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
"YjsSubstrateFactory.fromSnapshot only supports binary-encoded payloads",
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
const doc = new Y.Doc()
|
|
245
|
+
Y.applyUpdate(doc, payload.data)
|
|
246
|
+
return createYjsSubstrate(doc, schema)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
parseVersion(serialized: string): YjsVersion {
|
|
250
|
+
return YjsVersion.parse(serialized)
|
|
251
|
+
},
|
|
252
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// sync — sync primitives for YjsSubstrate-backed documents.
|
|
2
|
+
//
|
|
3
|
+
// These functions provide version tracking, snapshot export, and delta
|
|
4
|
+
// import for documents created via `createYjsDoc` or
|
|
5
|
+
// `createYjsDocFromSnapshot`. They discover the substrate via the
|
|
6
|
+
// module-scoped WeakMap in `create.ts`.
|
|
7
|
+
//
|
|
8
|
+
// Unlike PlainSubstrate's sync (which returns Op[] for deltas),
|
|
9
|
+
// YjsSubstrate's sync uses binary SubstratePayload for both snapshots
|
|
10
|
+
// and deltas — these are Yjs's native state-as-update bytes.
|
|
11
|
+
|
|
12
|
+
import type { SubstratePayload } from "@kyneta/schema"
|
|
13
|
+
import { YjsVersion } from "./version.js"
|
|
14
|
+
import { getSubstrate } from "./create.js"
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// version — current YjsVersion
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Current version as a `YjsVersion` (wrapping a Yjs state vector).
|
|
22
|
+
*
|
|
23
|
+
* Use `.serialize()` to get a text-safe string for embedding in HTML
|
|
24
|
+
* meta tags, URL parameters, etc.
|
|
25
|
+
*
|
|
26
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
|
|
27
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
|
|
28
|
+
*/
|
|
29
|
+
export function version(doc: object): YjsVersion {
|
|
30
|
+
return getSubstrate(doc).version()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// exportSnapshot — full state for reconstruction
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Export the full substrate snapshot — sufficient for a new peer to
|
|
39
|
+
* reconstruct an equivalent document via `createYjsDocFromSnapshot()`.
|
|
40
|
+
*
|
|
41
|
+
* Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
|
|
42
|
+
*
|
|
43
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
|
|
44
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
|
|
45
|
+
*/
|
|
46
|
+
export function exportSnapshot(doc: object): SubstratePayload {
|
|
47
|
+
return getSubstrate(doc).exportSnapshot()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// exportSince — delta since a version
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Export a delta payload containing all changes since the given version.
|
|
56
|
+
*
|
|
57
|
+
* Returns a binary `SubstratePayload` (Yjs update bytes), or `null`
|
|
58
|
+
* if the delta cannot be computed.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const v0 = version(docA)
|
|
62
|
+
* change(docA, d => d.title.insert(0, "Hi"))
|
|
63
|
+
* const delta = exportSince(docA, v0)
|
|
64
|
+
* importDelta(docB, delta!)
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
|
|
68
|
+
* @param since - The version to diff from.
|
|
69
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
|
|
70
|
+
*/
|
|
71
|
+
export function exportSince(
|
|
72
|
+
doc: object,
|
|
73
|
+
since: YjsVersion,
|
|
74
|
+
): SubstratePayload | null {
|
|
75
|
+
return getSubstrate(doc).exportSince(since)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// importDelta — apply a delta from another peer
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Import a delta payload into a live document.
|
|
84
|
+
*
|
|
85
|
+
* The payload must have been produced by `exportSince()` or
|
|
86
|
+
* `exportSnapshot()` on a compatible document.
|
|
87
|
+
*
|
|
88
|
+
* After import, the changefeed fires for all subscribers — the event
|
|
89
|
+
* bridge handles this automatically.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* const delta = exportSince(docA, sinceVersion)
|
|
93
|
+
* importDelta(docB, delta!, "sync")
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromSnapshot`.
|
|
97
|
+
* @param payload - The delta or snapshot payload to import.
|
|
98
|
+
* @param origin - Optional provenance tag for the changeset.
|
|
99
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
|
|
100
|
+
*/
|
|
101
|
+
export function importDelta(
|
|
102
|
+
doc: object,
|
|
103
|
+
payload: SubstratePayload,
|
|
104
|
+
origin?: string,
|
|
105
|
+
): void {
|
|
106
|
+
getSubstrate(doc).importDelta(payload, origin)
|
|
107
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// YjsVersion — Version implementation wrapping Yjs state vectors.
|
|
2
|
+
//
|
|
3
|
+
// Yjs state vectors (`Y.encodeStateVector(doc)`) are the complete peer
|
|
4
|
+
// state used for sync diffing — matching the semantics of kyneta's
|
|
5
|
+
// Version interface.
|
|
6
|
+
//
|
|
7
|
+
// Serialization uses base64-encoded bytes for text-safe embedding in
|
|
8
|
+
// HTML meta tags, script tags, etc.
|
|
9
|
+
//
|
|
10
|
+
// Yjs does not export a state vector comparison function, so we
|
|
11
|
+
// implement standard version-vector partial-order comparison over
|
|
12
|
+
// decoded `Map<number, number>` (clientID → clock) maps ourselves.
|
|
13
|
+
|
|
14
|
+
import type { Version } from "@kyneta/schema"
|
|
15
|
+
import { decodeStateVector } from "yjs"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Base64 helpers (platform-agnostic, no Node.js Buffer dependency)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
22
|
+
let binary = ""
|
|
23
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
24
|
+
binary += String.fromCharCode(bytes[i]!)
|
|
25
|
+
}
|
|
26
|
+
return btoa(binary)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function base64ToUint8Array(base64: string): Uint8Array {
|
|
30
|
+
const binary = atob(base64)
|
|
31
|
+
const bytes = new Uint8Array(binary.length)
|
|
32
|
+
for (let i = 0; i < binary.length; i++) {
|
|
33
|
+
bytes[i] = binary.charCodeAt(i)
|
|
34
|
+
}
|
|
35
|
+
return bytes
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// YjsVersion
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A Version wrapping a Yjs state vector.
|
|
44
|
+
*
|
|
45
|
+
* State vectors track the complete peer state — which operations from
|
|
46
|
+
* each client have been observed. This is the right abstraction for sync
|
|
47
|
+
* diffing: `exportSince(version)` uses the state vector to compute the
|
|
48
|
+
* minimal update payload via `Y.encodeStateAsUpdate(doc, sv)`.
|
|
49
|
+
*
|
|
50
|
+
* `serialize()` encodes to base64 for text-safe embedding.
|
|
51
|
+
* `compare()` decodes both state vectors and performs standard
|
|
52
|
+
* version-vector partial-order comparison over the client-clock maps.
|
|
53
|
+
*/
|
|
54
|
+
export class YjsVersion implements Version {
|
|
55
|
+
readonly sv: Uint8Array
|
|
56
|
+
|
|
57
|
+
constructor(sv: Uint8Array) {
|
|
58
|
+
this.sv = sv
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serialize the state vector to a base64 string.
|
|
63
|
+
*
|
|
64
|
+
* The encoding is: raw state vector bytes → base64.
|
|
65
|
+
* This is text-safe for embedding in HTML meta tags, URL parameters, etc.
|
|
66
|
+
*/
|
|
67
|
+
serialize(): string {
|
|
68
|
+
return uint8ArrayToBase64(this.sv)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Compare with another version using version-vector partial order.
|
|
73
|
+
*
|
|
74
|
+
* Decodes both state vectors via `Y.decodeStateVector()` to get
|
|
75
|
+
* `Map<number, number>` (clientID → clock), then compares:
|
|
76
|
+
*
|
|
77
|
+
* - Collect the union of all client IDs from both maps.
|
|
78
|
+
* - For each client, compare clocks (missing client = clock 0).
|
|
79
|
+
* - If all clocks in `this` ≤ `other` and at least one strictly less → `"behind"`
|
|
80
|
+
* - If all clocks in `this` ≥ `other` and at least one strictly greater → `"ahead"`
|
|
81
|
+
* - If all clocks equal → `"equal"`
|
|
82
|
+
* - Otherwise → `"concurrent"`
|
|
83
|
+
*
|
|
84
|
+
* Throws if `other` is not a `YjsVersion`.
|
|
85
|
+
*/
|
|
86
|
+
compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
|
|
87
|
+
if (!(other instanceof YjsVersion)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"YjsVersion can only be compared with another YjsVersion",
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const thisMap = decodeStateVector(this.sv)
|
|
94
|
+
const otherMap = decodeStateVector(other.sv)
|
|
95
|
+
|
|
96
|
+
// Collect the union of all client IDs
|
|
97
|
+
const allClients = new Set<number>()
|
|
98
|
+
for (const id of thisMap.keys()) allClients.add(id)
|
|
99
|
+
for (const id of otherMap.keys()) allClients.add(id)
|
|
100
|
+
|
|
101
|
+
let hasLess = false
|
|
102
|
+
let hasGreater = false
|
|
103
|
+
|
|
104
|
+
for (const clientId of allClients) {
|
|
105
|
+
const thisClock = thisMap.get(clientId) ?? 0
|
|
106
|
+
const otherClock = otherMap.get(clientId) ?? 0
|
|
107
|
+
|
|
108
|
+
if (thisClock < otherClock) {
|
|
109
|
+
hasLess = true
|
|
110
|
+
}
|
|
111
|
+
if (thisClock > otherClock) {
|
|
112
|
+
hasGreater = true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Early exit: if we've seen both less and greater, it's concurrent
|
|
116
|
+
if (hasLess && hasGreater) {
|
|
117
|
+
return "concurrent"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (hasLess && !hasGreater) return "behind"
|
|
122
|
+
if (hasGreater && !hasLess) return "ahead"
|
|
123
|
+
return "equal"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse a serialized YjsVersion string back into a YjsVersion.
|
|
128
|
+
*
|
|
129
|
+
* The inverse of `serialize()`: base64 → `Uint8Array`.
|
|
130
|
+
*/
|
|
131
|
+
static parse(serialized: string): YjsVersion {
|
|
132
|
+
if (serialized === "") {
|
|
133
|
+
throw new Error("Invalid YjsVersion value: (empty string)")
|
|
134
|
+
}
|
|
135
|
+
const bytes = base64ToUint8Array(serialized)
|
|
136
|
+
return new YjsVersion(bytes)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// yjs-escape — Yjs-specific escape hatch for accessing the Y.Doc
|
|
2
|
+
// backing a ref.
|
|
3
|
+
//
|
|
4
|
+
// `yjs(ref)` returns the `Y.Doc` backing a root document ref.
|
|
5
|
+
//
|
|
6
|
+
// The mapping is maintained via a WeakMap from Substrate → Y.Doc,
|
|
7
|
+
// populated by `registerYjsSubstrate()` (called during substrate
|
|
8
|
+
// creation). The `yjs()` function uses `unwrap()` from `@kyneta/schema`
|
|
9
|
+
// to get the substrate, then looks up the Y.Doc.
|
|
10
|
+
//
|
|
11
|
+
// This two-step approach (ref → substrate → Y.Doc) avoids duplicating
|
|
12
|
+
// the ref-tracking WeakMap and composes cleanly with the general
|
|
13
|
+
// `unwrap()` escape hatch.
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// import { yjs } from "@kyneta/yjs-schema"
|
|
17
|
+
//
|
|
18
|
+
// const doc = exchange.get("my-doc", TodoDoc)
|
|
19
|
+
// const yjsDoc = yjs(doc) // Y.Doc
|
|
20
|
+
// yjsDoc.getMap("root").toJSON() // raw Yjs inspection
|
|
21
|
+
|
|
22
|
+
import type { Doc as YDoc } from "yjs"
|
|
23
|
+
import type { Substrate } from "@kyneta/schema"
|
|
24
|
+
import { unwrap } from "@kyneta/schema"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Substrate → Y.Doc mapping
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const substrateToYjsDoc = new WeakMap<Substrate<any>, YDoc>()
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// registerYjsSubstrate — called during substrate creation
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register the Y.Doc backing a Yjs substrate.
|
|
38
|
+
*
|
|
39
|
+
* Called by `createYjsSubstrate()` and by `bindYjs`'s factory builder
|
|
40
|
+
* to enable the `yjs()` escape hatch. Must be called once per substrate
|
|
41
|
+
* at construction time.
|
|
42
|
+
*/
|
|
43
|
+
export function registerYjsSubstrate(
|
|
44
|
+
substrate: Substrate<any>,
|
|
45
|
+
doc: YDoc,
|
|
46
|
+
): void {
|
|
47
|
+
substrateToYjsDoc.set(substrate, doc)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// yjs — Yjs-specific escape hatch
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns the `Y.Doc` backing the given ref.
|
|
56
|
+
*
|
|
57
|
+
* This is the Yjs-specific escape hatch for accessing substrate-level
|
|
58
|
+
* capabilities: raw Yjs API, y-prosemirror/y-codemirror bindings,
|
|
59
|
+
* undo manager, awareness protocol, Yjs providers (y-websocket,
|
|
60
|
+
* y-indexeddb, y-webrtc, Hocuspocus, Liveblocks), etc.
|
|
61
|
+
*
|
|
62
|
+
* Currently supports root document refs only. Child-level resolution
|
|
63
|
+
* (e.g. `yjs(doc.title)` → `Y.Text`) is future work.
|
|
64
|
+
*
|
|
65
|
+
* @param ref - A root document ref backed by a Yjs substrate
|
|
66
|
+
* @returns The `Y.Doc` backing the ref
|
|
67
|
+
* @throws If the ref is not backed by a Yjs substrate
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { yjs } from "@kyneta/yjs-schema"
|
|
72
|
+
*
|
|
73
|
+
* const doc = exchange.get("my-doc", TodoDoc)
|
|
74
|
+
* const yjsDoc = yjs(doc)
|
|
75
|
+
* console.log(yjsDoc.getMap("root").toJSON()) // raw state
|
|
76
|
+
* console.log(yjsDoc.clientID) // client ID
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export function yjs(ref: object): YDoc {
|
|
80
|
+
let substrate: Substrate<any>
|
|
81
|
+
try {
|
|
82
|
+
substrate = unwrap(ref)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(
|
|
85
|
+
"yjs() requires a ref backed by a Yjs substrate. " +
|
|
86
|
+
"Use a doc created by exchange.get() with a bindYjs() schema, " +
|
|
87
|
+
"or by createYjsDoc().",
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const doc = substrateToYjsDoc.get(substrate)
|
|
92
|
+
if (!doc) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
"yjs() requires a ref backed by a Yjs substrate. " +
|
|
95
|
+
"The ref has a substrate but it is not a Yjs substrate. " +
|
|
96
|
+
"Use a doc created with a bindYjs() schema or createYjsDoc().",
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
return doc
|
|
100
|
+
}
|