@kyneta/yjs-schema 1.1.0 → 1.3.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 +3 -3
- package/dist/index.d.ts +97 -225
- package/dist/index.js +281 -316
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/bind-constraints.test.ts +325 -0
- package/src/__tests__/bind-yjs.test.ts +79 -70
- package/src/__tests__/create.test.ts +88 -65
- package/src/__tests__/reader.test.ts +38 -72
- package/src/__tests__/record-text-spike.test.ts +47 -46
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +62 -58
- package/src/__tests__/version.test.ts +75 -0
- package/src/bind-yjs.ts +40 -41
- package/src/change-mapping.ts +50 -54
- package/src/index.ts +29 -44
- package/src/native-map.ts +37 -0
- package/src/populate.ts +49 -82
- package/src/substrate.ts +68 -8
- package/src/version.ts +54 -52
- package/src/yjs-resolve.ts +0 -10
- package/src/create.ts +0 -177
- package/src/sync.ts +0 -107
- package/src/yjs-escape.ts +0 -84
package/src/substrate.ts
CHANGED
|
@@ -22,12 +22,18 @@ import type {
|
|
|
22
22
|
SubstratePayload,
|
|
23
23
|
WritableContext,
|
|
24
24
|
} from "@kyneta/schema"
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
BACKING_DOC,
|
|
27
|
+
buildWritableContext,
|
|
28
|
+
executeBatch,
|
|
29
|
+
KIND,
|
|
30
|
+
} from "@kyneta/schema"
|
|
26
31
|
import * as Y from "yjs"
|
|
27
32
|
import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
28
33
|
import { ensureContainers } from "./populate.js"
|
|
29
34
|
import { yjsReader } from "./reader.js"
|
|
30
35
|
import { YjsVersion } from "./version.js"
|
|
36
|
+
import { resolveYjsType } from "./yjs-resolve.js"
|
|
31
37
|
|
|
32
38
|
// ---------------------------------------------------------------------------
|
|
33
39
|
// Origin tag — used to suppress echo from our own transactions
|
|
@@ -101,7 +107,7 @@ export function createYjsSubstrate(
|
|
|
101
107
|
// wrappedPrepare (changefeed layer) still buffers the op.
|
|
102
108
|
},
|
|
103
109
|
|
|
104
|
-
onFlush(
|
|
110
|
+
onFlush(_origin?: string): void {
|
|
105
111
|
if (!inOurTransaction && pendingChanges.length > 0) {
|
|
106
112
|
// Local write: apply accumulated changes within a single
|
|
107
113
|
// Yjs transaction tagged with our origin for echo suppression.
|
|
@@ -124,6 +130,17 @@ export function createYjsSubstrate(
|
|
|
124
130
|
context(): WritableContext {
|
|
125
131
|
if (!cachedCtx) {
|
|
126
132
|
cachedCtx = buildWritableContext(substrate)
|
|
133
|
+
// Attach nativeResolver — used by interpretImpl to set [NATIVE]
|
|
134
|
+
// on every ref. The resolver maps schema positions to Yjs shared types.
|
|
135
|
+
;(cachedCtx as any).nativeResolver = (
|
|
136
|
+
nodeSchema: SchemaNode,
|
|
137
|
+
path: { segments: readonly unknown[] },
|
|
138
|
+
) => {
|
|
139
|
+
if (path.segments.length === 0) return doc
|
|
140
|
+
if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
|
|
141
|
+
return undefined
|
|
142
|
+
return resolveYjsType(rootMap, schema, path as any)
|
|
143
|
+
}
|
|
127
144
|
}
|
|
128
145
|
return cachedCtx
|
|
129
146
|
},
|
|
@@ -132,6 +149,18 @@ export function createYjsSubstrate(
|
|
|
132
149
|
return new YjsVersion(Y.encodeStateVector(doc))
|
|
133
150
|
},
|
|
134
151
|
|
|
152
|
+
baseVersion(): YjsVersion {
|
|
153
|
+
// Yjs substrate: base is always the initial state (no advance supported).
|
|
154
|
+
return new YjsVersion(new Uint8Array([0]))
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
advance(_to: YjsVersion): void {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"advance() on a live Yjs substrate is not yet supported. " +
|
|
160
|
+
"Use advance() on a YjsReplica instead.",
|
|
161
|
+
)
|
|
162
|
+
},
|
|
163
|
+
|
|
135
164
|
exportEntirety(): SubstratePayload {
|
|
136
165
|
return {
|
|
137
166
|
kind: "entirety",
|
|
@@ -180,7 +209,7 @@ export function createYjsSubstrate(
|
|
|
180
209
|
}
|
|
181
210
|
|
|
182
211
|
// Convert Yjs events → kyneta Ops
|
|
183
|
-
const ops = eventsToOps(events)
|
|
212
|
+
const ops = eventsToOps(events, schema)
|
|
184
213
|
if (ops.length === 0) {
|
|
185
214
|
return
|
|
186
215
|
}
|
|
@@ -239,24 +268,55 @@ export function createYjsSubstrate(
|
|
|
239
268
|
* storage without ever interpreting document fields.
|
|
240
269
|
*/
|
|
241
270
|
export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
271
|
+
let currentDoc = doc
|
|
272
|
+
let currentBase: YjsVersion = new YjsVersion(Y.encodeStateVector(new Y.Doc()))
|
|
273
|
+
|
|
242
274
|
return {
|
|
243
|
-
[BACKING_DOC]
|
|
275
|
+
get [BACKING_DOC]() {
|
|
276
|
+
return currentDoc
|
|
277
|
+
},
|
|
244
278
|
|
|
245
279
|
version(): YjsVersion {
|
|
246
|
-
return new YjsVersion(Y.encodeStateVector(
|
|
280
|
+
return new YjsVersion(Y.encodeStateVector(currentDoc))
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
baseVersion(): YjsVersion {
|
|
284
|
+
return currentBase
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
advance(to: YjsVersion): void {
|
|
288
|
+
const baseCmp = currentBase.compare(to)
|
|
289
|
+
if (baseCmp === "ahead") {
|
|
290
|
+
throw new Error("advance(): target is behind base version")
|
|
291
|
+
}
|
|
292
|
+
const currentCmp = to.compare(this.version())
|
|
293
|
+
if (currentCmp === "ahead") {
|
|
294
|
+
throw new Error("advance(): target is ahead of current version")
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Yjs can only do full projection (to = version).
|
|
298
|
+
// For any to < version, it's a no-op — undershoot contract.
|
|
299
|
+
if (currentCmp !== "equal") return
|
|
300
|
+
|
|
301
|
+
// Full projection: create a new doc with current state, no history.
|
|
302
|
+
const update = Y.encodeStateAsUpdate(currentDoc)
|
|
303
|
+
const newDoc = new Y.Doc()
|
|
304
|
+
Y.applyUpdate(newDoc, update)
|
|
305
|
+
currentDoc = newDoc
|
|
306
|
+
currentBase = new YjsVersion(Y.encodeStateVector(currentDoc))
|
|
247
307
|
},
|
|
248
308
|
|
|
249
309
|
exportEntirety(): SubstratePayload {
|
|
250
310
|
return {
|
|
251
311
|
kind: "entirety",
|
|
252
312
|
encoding: "binary",
|
|
253
|
-
data: Y.encodeStateAsUpdate(
|
|
313
|
+
data: Y.encodeStateAsUpdate(currentDoc),
|
|
254
314
|
}
|
|
255
315
|
},
|
|
256
316
|
|
|
257
317
|
exportSince(since: YjsVersion): SubstratePayload | null {
|
|
258
318
|
try {
|
|
259
|
-
const bytes = Y.encodeStateAsUpdate(
|
|
319
|
+
const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
|
|
260
320
|
return { kind: "since", encoding: "binary", data: bytes }
|
|
261
321
|
} catch {
|
|
262
322
|
return null
|
|
@@ -273,7 +333,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
273
333
|
"If you recently switched CRDT backends, stale clients may be sending incompatible data.",
|
|
274
334
|
)
|
|
275
335
|
}
|
|
276
|
-
Y.applyUpdate(
|
|
336
|
+
Y.applyUpdate(currentDoc, payload.data)
|
|
277
337
|
},
|
|
278
338
|
} as Replica<YjsVersion>
|
|
279
339
|
}
|
package/src/version.ts
CHANGED
|
@@ -12,27 +12,45 @@
|
|
|
12
12
|
// decoded `Map<number, number>` (clientID → clock) maps ourselves.
|
|
13
13
|
|
|
14
14
|
import type { Version } from "@kyneta/schema"
|
|
15
|
+
import {
|
|
16
|
+
base64ToUint8Array,
|
|
17
|
+
uint8ArrayToBase64,
|
|
18
|
+
versionVectorCompare,
|
|
19
|
+
versionVectorMeet,
|
|
20
|
+
} from "@kyneta/schema"
|
|
15
21
|
import { decodeStateVector } from "yjs"
|
|
16
22
|
|
|
17
23
|
// ---------------------------------------------------------------------------
|
|
18
|
-
//
|
|
24
|
+
// State vector encoding — manual varint (unsigned LEB128)
|
|
19
25
|
// ---------------------------------------------------------------------------
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Encode a state vector map to Yjs's binary state vector format.
|
|
29
|
+
*
|
|
30
|
+
* Yjs does not export `encodeStateVector(map)` — only `Y.encodeStateVector(doc)`
|
|
31
|
+
* which requires a full doc. This implements the same binary format directly:
|
|
32
|
+
* `[entryCount: varint, (clientId: varint, clock: varint)*]`
|
|
33
|
+
*
|
|
34
|
+
* Each value is encoded as an unsigned LEB128 varint.
|
|
35
|
+
*/
|
|
36
|
+
function encodeStateVector(map: Map<number, number>): Uint8Array {
|
|
37
|
+
const bytes: number[] = []
|
|
38
|
+
|
|
39
|
+
function writeVarUint(value: number): void {
|
|
40
|
+
while (value > 0x7f) {
|
|
41
|
+
bytes.push((value & 0x7f) | 0x80)
|
|
42
|
+
value >>>= 7
|
|
43
|
+
}
|
|
44
|
+
bytes.push(value & 0x7f)
|
|
25
45
|
}
|
|
26
|
-
return btoa(binary)
|
|
27
|
-
}
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
bytes[i] = binary.charCodeAt(i)
|
|
47
|
+
writeVarUint(map.size)
|
|
48
|
+
for (const [clientId, clock] of map) {
|
|
49
|
+
writeVarUint(clientId)
|
|
50
|
+
writeVarUint(clock)
|
|
34
51
|
}
|
|
35
|
-
|
|
52
|
+
|
|
53
|
+
return new Uint8Array(bytes)
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
// ---------------------------------------------------------------------------
|
|
@@ -71,54 +89,38 @@ export class YjsVersion implements Version {
|
|
|
71
89
|
/**
|
|
72
90
|
* Compare with another version using version-vector partial order.
|
|
73
91
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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"`
|
|
92
|
+
* Delegates to the shared `versionVectorCompare` utility after decoding
|
|
93
|
+
* both state vectors via `Y.decodeStateVector()`.
|
|
83
94
|
*
|
|
84
|
-
*
|
|
95
|
+
* @throws If `other` is not a `YjsVersion`.
|
|
85
96
|
*/
|
|
86
97
|
compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
|
|
87
98
|
if (!(other instanceof YjsVersion)) {
|
|
88
99
|
throw new Error("YjsVersion can only be compared with another YjsVersion")
|
|
89
100
|
}
|
|
101
|
+
return versionVectorCompare(
|
|
102
|
+
decodeStateVector(this.sv),
|
|
103
|
+
decodeStateVector(other.sv),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
90
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Greatest lower bound (lattice meet) of two Yjs versions.
|
|
109
|
+
*
|
|
110
|
+
* Decodes both state vectors, computes the component-wise minimum
|
|
111
|
+
* via the shared `versionVectorMeet` utility, and encodes the result
|
|
112
|
+
* back to a Yjs state vector.
|
|
113
|
+
*
|
|
114
|
+
* @throws If `other` is not a `YjsVersion`.
|
|
115
|
+
*/
|
|
116
|
+
meet(other: Version): YjsVersion {
|
|
117
|
+
if (!(other instanceof YjsVersion)) {
|
|
118
|
+
throw new Error("YjsVersion can only be meet'd with another YjsVersion")
|
|
119
|
+
}
|
|
91
120
|
const thisMap = decodeStateVector(this.sv)
|
|
92
121
|
const otherMap = decodeStateVector(other.sv)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const allClients = new Set<number>()
|
|
96
|
-
for (const id of thisMap.keys()) allClients.add(id)
|
|
97
|
-
for (const id of otherMap.keys()) allClients.add(id)
|
|
98
|
-
|
|
99
|
-
let hasLess = false
|
|
100
|
-
let hasGreater = false
|
|
101
|
-
|
|
102
|
-
for (const clientId of allClients) {
|
|
103
|
-
const thisClock = thisMap.get(clientId) ?? 0
|
|
104
|
-
const otherClock = otherMap.get(clientId) ?? 0
|
|
105
|
-
|
|
106
|
-
if (thisClock < otherClock) {
|
|
107
|
-
hasLess = true
|
|
108
|
-
}
|
|
109
|
-
if (thisClock > otherClock) {
|
|
110
|
-
hasGreater = true
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Early exit: if we've seen both less and greater, it's concurrent
|
|
114
|
-
if (hasLess && hasGreater) {
|
|
115
|
-
return "concurrent"
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (hasLess && !hasGreater) return "behind"
|
|
120
|
-
if (hasGreater && !hasLess) return "ahead"
|
|
121
|
-
return "equal"
|
|
122
|
+
const result = versionVectorMeet(thisMap, otherMap)
|
|
123
|
+
return new YjsVersion(encodeStateVector(result))
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
package/src/yjs-resolve.ts
CHANGED
|
@@ -78,16 +78,6 @@ export function resolveYjsType(
|
|
|
78
78
|
let current: unknown = rootMap
|
|
79
79
|
let schema = rootSchema
|
|
80
80
|
|
|
81
|
-
// Unwrap the root annotation (e.g. annotated("doc", product))
|
|
82
|
-
// to reach the product schema whose fields are the root map's children.
|
|
83
|
-
let rootProduct = rootSchema
|
|
84
|
-
while (
|
|
85
|
-
rootProduct._kind === "annotated" &&
|
|
86
|
-
rootProduct.schema !== undefined
|
|
87
|
-
) {
|
|
88
|
-
rootProduct = rootProduct.schema
|
|
89
|
-
}
|
|
90
|
-
|
|
91
81
|
for (let i = 0; i < path.length; i++) {
|
|
92
82
|
const seg = path.segments[i]!
|
|
93
83
|
const nextSchema = advanceSchema(schema, seg)
|
package/src/create.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
// create — batteries-included document construction backed by YjsSubstrate.
|
|
2
|
-
//
|
|
3
|
-
// Provides `createYjsDoc` and `createYjsDocFromEntirety` functions that
|
|
4
|
-
// hide the interpret pipeline and layer composition behind a single call.
|
|
5
|
-
//
|
|
6
|
-
// Internally tracks substrates via a module-scoped WeakMap so that sync
|
|
7
|
-
// primitives (`version`, `exportEntirety`, `merge` in sync.ts)
|
|
8
|
-
// can retrieve the substrate from just a doc ref.
|
|
9
|
-
//
|
|
10
|
-
// `getSubstrate` is exported for use by `sync.ts` but is NOT re-exported
|
|
11
|
-
// from the barrel (`index.ts`). It is an internal cross-module helper.
|
|
12
|
-
//
|
|
13
|
-
// Two forms for `createYjsDoc`:
|
|
14
|
-
// createYjsDoc(schema, yjsDoc) — "bring your own doc" (wrap existing)
|
|
15
|
-
// createYjsDoc(schema) — create a fresh empty Y.Doc
|
|
16
|
-
|
|
17
|
-
import type {
|
|
18
|
-
Ref,
|
|
19
|
-
Schema as SchemaType,
|
|
20
|
-
Substrate,
|
|
21
|
-
SubstratePayload,
|
|
22
|
-
} from "@kyneta/schema"
|
|
23
|
-
import {
|
|
24
|
-
changefeed,
|
|
25
|
-
interpret,
|
|
26
|
-
readable,
|
|
27
|
-
registerSubstrate,
|
|
28
|
-
writable,
|
|
29
|
-
} from "@kyneta/schema"
|
|
30
|
-
import type * as Y from "yjs"
|
|
31
|
-
import { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
|
|
32
|
-
import type { YjsVersion } from "./version.js"
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Substrate tracking (module-scoped)
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
const substrates = new WeakMap<object, Substrate<YjsVersion>>()
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Retrieve the substrate associated with a doc created by `createYjsDoc`
|
|
42
|
-
* or `createYjsDocFromEntirety`.
|
|
43
|
-
*
|
|
44
|
-
* Exported for `sync.ts` — NOT re-exported from the barrel.
|
|
45
|
-
*
|
|
46
|
-
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
47
|
-
*/
|
|
48
|
-
export function getSubstrate(doc: object): Substrate<YjsVersion> {
|
|
49
|
-
const s = substrates.get(doc)
|
|
50
|
-
if (!s) {
|
|
51
|
-
throw new Error(
|
|
52
|
-
"version/exportEntirety/merge called on an object without a YjsSubstrate. " +
|
|
53
|
-
"Use a doc created by createYjsDoc() or createYjsDocFromEntirety().",
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
return s
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// registerDoc — internal helper (interpret + WeakMap registration)
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
function registerDoc(
|
|
64
|
-
schema: SchemaType,
|
|
65
|
-
substrate: Substrate<YjsVersion>,
|
|
66
|
-
): any {
|
|
67
|
-
// The `as any` on the builder avoids TS2589 — interpret's fluent API
|
|
68
|
-
// produces deeply recursive types when S is the abstract SchemaType.
|
|
69
|
-
// The public createYjsDoc/createYjsDocFromEntirety signatures provide
|
|
70
|
-
// the correct Ref<S> return type via interface call signature patterns.
|
|
71
|
-
const doc: any = (interpret as any)(schema, substrate.context())
|
|
72
|
-
.with(readable)
|
|
73
|
-
.with(writable)
|
|
74
|
-
.with(changefeed)
|
|
75
|
-
.done()
|
|
76
|
-
substrates.set(doc, substrate)
|
|
77
|
-
// Also register in the general unwrap() registry so that the
|
|
78
|
-
// yjs() escape hatch can discover the substrate from the ref.
|
|
79
|
-
registerSubstrate(doc, substrate)
|
|
80
|
-
return doc
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// isYDoc — runtime check for Y.Doc
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
function isYDoc(value: unknown): value is Y.Doc {
|
|
88
|
-
return (
|
|
89
|
-
value !== null &&
|
|
90
|
-
value !== undefined &&
|
|
91
|
-
typeof value === "object" &&
|
|
92
|
-
"getMap" in value &&
|
|
93
|
-
"getText" in value &&
|
|
94
|
-
"getArray" in value &&
|
|
95
|
-
"transact" in value &&
|
|
96
|
-
typeof (value as any).transact === "function" &&
|
|
97
|
-
// Y.Doc has clientID; distinguish from other objects
|
|
98
|
-
"clientID" in value &&
|
|
99
|
-
typeof (value as any).clientID === "number"
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// createYjsDoc
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
// Interface call signature avoids TS2589 on Ref<S> when S is generic.
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Create a live Yjs-backed document.
|
|
111
|
-
*
|
|
112
|
-
* **Form 1 — bring your own doc:**
|
|
113
|
-
* ```ts
|
|
114
|
-
* const yjsDoc = new Y.Doc()
|
|
115
|
-
* const doc = createYjsDoc(mySchema, yjsDoc)
|
|
116
|
-
* ```
|
|
117
|
-
*
|
|
118
|
-
* **Form 2 — fresh empty doc:**
|
|
119
|
-
* ```ts
|
|
120
|
-
* const doc = createYjsDoc(mySchema)
|
|
121
|
-
*
|
|
122
|
-
* // Apply initial content via change():
|
|
123
|
-
* change(doc, d => {
|
|
124
|
-
* d.title.insert(0, "Hello")
|
|
125
|
-
* d.items.push({ name: "First item" })
|
|
126
|
-
* })
|
|
127
|
-
* ```
|
|
128
|
-
*
|
|
129
|
-
* Returns a full-stack `Ref<S>` — callable, navigable, writable,
|
|
130
|
-
* transactable, and observable. Backed by a `YjsSubstrate` with
|
|
131
|
-
* CRDT collaboration support.
|
|
132
|
-
*
|
|
133
|
-
* The returned ref observes **all** mutations to the underlying Y.Doc,
|
|
134
|
-
* regardless of source (local kyneta writes, merge, external
|
|
135
|
-
* `Y.applyUpdate()`, external raw Yjs API mutations).
|
|
136
|
-
*
|
|
137
|
-
* @param schema - The schema describing the document structure.
|
|
138
|
-
* @param doc - Optional `Y.Doc` instance to wrap. If omitted, a fresh
|
|
139
|
-
* empty Y.Doc is created with containers matching the schema.
|
|
140
|
-
*/
|
|
141
|
-
type CreateYjsDoc = <S extends SchemaType>(schema: S, doc?: Y.Doc) => Ref<S>
|
|
142
|
-
|
|
143
|
-
export const createYjsDoc: CreateYjsDoc = (schema, doc) => {
|
|
144
|
-
if (doc !== undefined && isYDoc(doc)) {
|
|
145
|
-
// Bring your own doc — wrap the existing Y.Doc
|
|
146
|
-
return registerDoc(schema, createYjsSubstrate(doc, schema))
|
|
147
|
-
}
|
|
148
|
-
// Fresh empty doc
|
|
149
|
-
return registerDoc(schema, yjsSubstrateFactory.create(schema))
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
// createYjsDocFromEntirety
|
|
154
|
-
// ---------------------------------------------------------------------------
|
|
155
|
-
|
|
156
|
-
type CreateYjsDocFromEntirety = <S extends SchemaType>(
|
|
157
|
-
schema: S,
|
|
158
|
-
payload: SubstratePayload,
|
|
159
|
-
) => Ref<S>
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Reconstruct a live Yjs-backed document from a substrate entirety payload.
|
|
163
|
-
*
|
|
164
|
-
* The payload must have been produced by `exportEntirety()` on a
|
|
165
|
-
* compatible document. This is the entry point for SSR hydration
|
|
166
|
-
* and reconnection past log compaction.
|
|
167
|
-
*
|
|
168
|
-
* ```ts
|
|
169
|
-
* const payload = exportEntirety(docA)
|
|
170
|
-
* const docB = createYjsDocFromEntirety(MySchema, payload)
|
|
171
|
-
* // docB has the same state as docA at the time of export
|
|
172
|
-
* ```
|
|
173
|
-
*/
|
|
174
|
-
export const createYjsDocFromEntirety: CreateYjsDocFromEntirety = (
|
|
175
|
-
schema,
|
|
176
|
-
payload,
|
|
177
|
-
) => registerDoc(schema, yjsSubstrateFactory.fromEntirety(payload, schema))
|
package/src/sync.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
// sync — sync primitives for YjsSubstrate-backed documents.
|
|
2
|
-
//
|
|
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
|
-
// 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 entireties
|
|
10
|
-
// and deltas — these are Yjs's native state-as-update bytes.
|
|
11
|
-
|
|
12
|
-
import type { SubstratePayload } from "@kyneta/schema"
|
|
13
|
-
import { getSubstrate } from "./create.js"
|
|
14
|
-
import type { YjsVersion } from "./version.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 `createYjsDocFromEntirety`.
|
|
27
|
-
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
28
|
-
*/
|
|
29
|
-
export function version(doc: object): YjsVersion {
|
|
30
|
-
return getSubstrate(doc).version()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// exportEntirety — full state for reconstruction
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Export the full substrate entirety — sufficient for a new peer to
|
|
39
|
-
* reconstruct an equivalent document via `createYjsDocFromEntirety()`.
|
|
40
|
-
*
|
|
41
|
-
* Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
|
|
42
|
-
*
|
|
43
|
-
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
44
|
-
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
45
|
-
*/
|
|
46
|
-
export function exportEntirety(doc: object): SubstratePayload {
|
|
47
|
-
return getSubstrate(doc).exportEntirety()
|
|
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
|
-
* merge(docB, delta!)
|
|
65
|
-
* ```
|
|
66
|
-
*
|
|
67
|
-
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
68
|
-
* @param since - The version to diff from.
|
|
69
|
-
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
70
|
-
*/
|
|
71
|
-
export function exportSince(
|
|
72
|
-
doc: object,
|
|
73
|
-
since: YjsVersion,
|
|
74
|
-
): SubstratePayload | null {
|
|
75
|
-
return getSubstrate(doc).exportSince(since)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// merge — 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
|
-
* `exportEntirety()` 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
|
-
* merge(docB, delta!, "sync")
|
|
94
|
-
* ```
|
|
95
|
-
*
|
|
96
|
-
* @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
|
|
97
|
-
* @param payload - The delta or entirety payload to merge.
|
|
98
|
-
* @param origin - Optional provenance tag for the changeset.
|
|
99
|
-
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
|
|
100
|
-
*/
|
|
101
|
-
export function merge(
|
|
102
|
-
doc: object,
|
|
103
|
-
payload: SubstratePayload,
|
|
104
|
-
origin?: string,
|
|
105
|
-
): void {
|
|
106
|
-
getSubstrate(doc).merge(payload, origin)
|
|
107
|
-
}
|
package/src/yjs-escape.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
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 substrate exposes its backing Y.Doc via the `BACKING_DOC` symbol
|
|
7
|
-
// (from `@kyneta/schema`). The `yjs()` function uses `unwrap()` to get
|
|
8
|
-
// the substrate, then reads `[BACKING_DOC]` to get the Y.Doc.
|
|
9
|
-
//
|
|
10
|
-
// This two-step approach (ref → substrate → Y.Doc) avoids duplicating
|
|
11
|
-
// the ref-tracking WeakMap and composes cleanly with the general
|
|
12
|
-
// `unwrap()` escape hatch.
|
|
13
|
-
//
|
|
14
|
-
// Context: jj:smmulzkm (BACKING_DOC replaces WeakMap + registerYjsSubstrate)
|
|
15
|
-
//
|
|
16
|
-
// Usage:
|
|
17
|
-
// import { yjs } from "@kyneta/yjs-schema"
|
|
18
|
-
//
|
|
19
|
-
// const doc = exchange.get("my-doc", TodoDoc)
|
|
20
|
-
// const yjsDoc = yjs(doc) // Y.Doc
|
|
21
|
-
// yjsDoc.getMap("root").toJSON() // raw Yjs inspection
|
|
22
|
-
|
|
23
|
-
import { BACKING_DOC, unwrap } from "@kyneta/schema"
|
|
24
|
-
import type { Doc as YDoc } from "yjs"
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// yjs — Yjs-specific escape hatch
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Returns the `Y.Doc` backing the given ref.
|
|
32
|
-
*
|
|
33
|
-
* This is the Yjs-specific escape hatch for accessing substrate-level
|
|
34
|
-
* capabilities: raw Yjs API, y-prosemirror/y-codemirror bindings,
|
|
35
|
-
* undo manager, awareness protocol, Yjs providers (y-websocket,
|
|
36
|
-
* y-indexeddb, y-webrtc, Hocuspocus, Liveblocks), etc.
|
|
37
|
-
*
|
|
38
|
-
* Currently supports root document refs only. Child-level resolution
|
|
39
|
-
* (e.g. `yjs(doc.title)` → `Y.Text`) is future work.
|
|
40
|
-
*
|
|
41
|
-
* @param ref - A root document ref backed by a Yjs substrate
|
|
42
|
-
* @returns The `Y.Doc` backing the ref
|
|
43
|
-
* @throws If the ref is not backed by a Yjs substrate
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* ```ts
|
|
47
|
-
* import { yjs } from "@kyneta/yjs-schema"
|
|
48
|
-
*
|
|
49
|
-
* const doc = exchange.get("my-doc", TodoDoc)
|
|
50
|
-
* const yjsDoc = yjs(doc)
|
|
51
|
-
* console.log(yjsDoc.getMap("root").toJSON()) // raw state
|
|
52
|
-
* console.log(yjsDoc.clientID) // client ID
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
export function yjs(ref: object): YDoc {
|
|
56
|
-
let substrate: any
|
|
57
|
-
try {
|
|
58
|
-
substrate = unwrap(ref)
|
|
59
|
-
} catch {
|
|
60
|
-
throw new Error(
|
|
61
|
-
"yjs() requires a ref backed by a Yjs substrate. " +
|
|
62
|
-
"Use a doc created by exchange.get() with a bindYjs() schema, " +
|
|
63
|
-
"or by createYjsDoc().",
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const doc = substrate[BACKING_DOC]
|
|
68
|
-
// Duck-type check: Y.Doc has getMap, encodeStateVector-compatible API,
|
|
69
|
-
// and a numeric clientID. A PlainState (plain object) or LoroDoc would
|
|
70
|
-
// not have getMap as a function.
|
|
71
|
-
if (
|
|
72
|
-
!doc ||
|
|
73
|
-
typeof doc !== "object" ||
|
|
74
|
-
typeof (doc as any).getMap !== "function" ||
|
|
75
|
-
typeof (doc as any).clientID !== "number"
|
|
76
|
-
) {
|
|
77
|
-
throw new Error(
|
|
78
|
-
"yjs() requires a ref backed by a Yjs substrate. " +
|
|
79
|
-
"The ref has a substrate but it is not a Yjs substrate. " +
|
|
80
|
-
"Use a doc created with a bindYjs() schema or createYjsDoc().",
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
return doc as YDoc
|
|
84
|
-
}
|