@kyneta/yjs-schema 1.1.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 +34 -74
- package/dist/index.js +181 -132
- 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 +38 -40
- package/src/__tests__/create.test.ts +10 -11
- package/src/__tests__/reader.test.ts +38 -61
- package/src/__tests__/record-text-spike.test.ts +9 -10
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +18 -21
- package/src/__tests__/version.test.ts +75 -0
- package/src/bind-yjs.ts +72 -42
- package/src/change-mapping.ts +46 -55
- package/src/create.ts +2 -2
- package/src/index.ts +12 -25
- package/src/populate.ts +50 -83
- package/src/substrate.ts +52 -7
- package/src/version.ts +55 -0
- package/src/yjs-resolve.ts +1 -11
- package/src/yjs-escape.ts +0 -84
package/src/change-mapping.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type {
|
|
|
29
29
|
TextChange,
|
|
30
30
|
TextInstruction,
|
|
31
31
|
} from "@kyneta/schema"
|
|
32
|
-
import { advanceSchema, expandMapOpsToLeaves, RawPath } from "@kyneta/schema"
|
|
32
|
+
import { advanceSchema, expandMapOpsToLeaves, KIND, RawPath } from "@kyneta/schema"
|
|
33
33
|
import * as Y from "yjs"
|
|
34
34
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
35
35
|
|
|
@@ -74,15 +74,15 @@ export function applyChangeToYjs(
|
|
|
74
74
|
|
|
75
75
|
case "increment":
|
|
76
76
|
throw new Error(
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
78
|
+
`Counter requires a CRDT backend that supports counters (e.g. Loro). ` +
|
|
79
79
|
`Attempted IncrementChange with amount=${(change as IncrementChange).amount} at path [${pathToString(path)}].`,
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
case "tree":
|
|
83
83
|
throw new Error(
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
85
|
+
`Tree requires a CRDT backend that supports trees (e.g. Loro). ` +
|
|
86
86
|
`Attempted TreeChange at path [${pathToString(path)}].`,
|
|
87
87
|
)
|
|
88
88
|
|
|
@@ -250,24 +250,16 @@ function maybeCreateSharedType(
|
|
|
250
250
|
): unknown {
|
|
251
251
|
if (schema === undefined) return value
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
text
|
|
253
|
+
switch (schema[KIND]) {
|
|
254
|
+
// First-class text → Y.Text
|
|
255
|
+
case "text": {
|
|
256
|
+
const text = new Y.Text()
|
|
257
|
+
if (typeof value === "string" && value.length > 0) {
|
|
258
|
+
text.insert(0, value)
|
|
259
|
+
}
|
|
260
|
+
return text
|
|
261
261
|
}
|
|
262
|
-
return text
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Annotated counter/movable/tree → should not reach here (thrown earlier)
|
|
266
|
-
if (tag === "counter" || tag === "movable" || tag === "tree") {
|
|
267
|
-
throw new Error(`Yjs substrate does not support "${tag}" annotations.`)
|
|
268
|
-
}
|
|
269
262
|
|
|
270
|
-
switch (structural._kind) {
|
|
271
263
|
case "product": {
|
|
272
264
|
if (
|
|
273
265
|
value === null ||
|
|
@@ -277,13 +269,13 @@ function maybeCreateSharedType(
|
|
|
277
269
|
) {
|
|
278
270
|
return value
|
|
279
271
|
}
|
|
280
|
-
return createStructuredMap(value as Record<string, unknown>,
|
|
272
|
+
return createStructuredMap(value as Record<string, unknown>, schema)
|
|
281
273
|
}
|
|
282
274
|
|
|
283
275
|
case "sequence": {
|
|
284
276
|
if (!Array.isArray(value)) return value
|
|
285
277
|
const arr = new Y.Array()
|
|
286
|
-
const itemSchema =
|
|
278
|
+
const itemSchema = schema.item
|
|
287
279
|
const items = (value as unknown[]).map(item =>
|
|
288
280
|
maybeCreateSharedType(item, itemSchema),
|
|
289
281
|
)
|
|
@@ -301,15 +293,26 @@ function maybeCreateSharedType(
|
|
|
301
293
|
return value
|
|
302
294
|
}
|
|
303
295
|
const map = new Y.Map()
|
|
304
|
-
const valueSchema =
|
|
296
|
+
const valueSchema = schema.item
|
|
305
297
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
306
298
|
map.set(k, maybeCreateSharedType(v, valueSchema))
|
|
307
299
|
}
|
|
308
300
|
return map
|
|
309
301
|
}
|
|
310
302
|
|
|
303
|
+
// Unsupported first-class CRDT types — should not reach here
|
|
304
|
+
// (rejected at bind time by caps check)
|
|
305
|
+
case "counter":
|
|
306
|
+
case "set":
|
|
307
|
+
case "tree":
|
|
308
|
+
case "movable":
|
|
309
|
+
throw new Error(
|
|
310
|
+
`Yjs substrate does not support [KIND]="${schema[KIND]}". ` +
|
|
311
|
+
`This should have been caught at bind() time.`,
|
|
312
|
+
)
|
|
313
|
+
|
|
311
314
|
default:
|
|
312
|
-
// Scalar, sum
|
|
315
|
+
// Scalar, sum — return as plain value
|
|
313
316
|
return value
|
|
314
317
|
}
|
|
315
318
|
}
|
|
@@ -326,9 +329,8 @@ function createStructuredMap(
|
|
|
326
329
|
productSchema: SchemaNode,
|
|
327
330
|
): Y.Map<any> {
|
|
328
331
|
const map = new Y.Map()
|
|
329
|
-
const structural = unwrapAnnotations(productSchema)
|
|
330
332
|
|
|
331
|
-
if (
|
|
333
|
+
if (productSchema[KIND] !== "product") {
|
|
332
334
|
// Fallback: set all values as plain
|
|
333
335
|
for (const [key, val] of Object.entries(obj)) {
|
|
334
336
|
map.set(key, val)
|
|
@@ -339,25 +341,22 @@ function createStructuredMap(
|
|
|
339
341
|
// Process fields present in the value object
|
|
340
342
|
for (const [key, val] of Object.entries(obj)) {
|
|
341
343
|
if (val === undefined) continue
|
|
342
|
-
const fieldSchema =
|
|
344
|
+
const fieldSchema = productSchema.fields[key]
|
|
343
345
|
const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val
|
|
344
346
|
map.set(key, yjsVal)
|
|
345
347
|
}
|
|
346
348
|
|
|
347
|
-
// Create shared types for
|
|
349
|
+
// Create shared types for first-class CRDT fields declared in the schema
|
|
348
350
|
// but missing from the value object. This ensures Yjs containers
|
|
349
351
|
// exist for later mutation (e.g. .insert() on a text field inside
|
|
350
352
|
// a struct inside a record/list).
|
|
351
353
|
for (const [key, fieldSchema] of Object.entries(
|
|
352
|
-
|
|
354
|
+
productSchema.fields as Record<string, SchemaNode>,
|
|
353
355
|
)) {
|
|
354
356
|
if (key in obj) continue // already processed above
|
|
355
|
-
|
|
356
|
-
if (tag === "text") {
|
|
357
|
+
if (fieldSchema[KIND] === "text") {
|
|
357
358
|
map.set(key, new Y.Text())
|
|
358
359
|
}
|
|
359
|
-
// Other annotated container types (counter, movable, tree) are
|
|
360
|
-
// unsupported in Yjs and will throw if used elsewhere.
|
|
361
360
|
}
|
|
362
361
|
|
|
363
362
|
return map
|
|
@@ -380,7 +379,7 @@ function createStructuredMap(
|
|
|
380
379
|
*
|
|
381
380
|
* @param events - The events from the `observeDeep` callback
|
|
382
381
|
*/
|
|
383
|
-
export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
|
|
382
|
+
export function eventsToOps(events: Y.YEvent<any>[], schema: SchemaNode): Op[] {
|
|
384
383
|
const ops: Op[] = []
|
|
385
384
|
|
|
386
385
|
for (const event of events) {
|
|
@@ -391,7 +390,7 @@ export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
|
|
|
391
390
|
}
|
|
392
391
|
}
|
|
393
392
|
|
|
394
|
-
return expandMapOpsToLeaves(ops)
|
|
393
|
+
return expandMapOpsToLeaves(ops, schema)
|
|
395
394
|
}
|
|
396
395
|
|
|
397
396
|
// ---------------------------------------------------------------------------
|
|
@@ -541,17 +540,6 @@ function extractEventValue(value: unknown): unknown {
|
|
|
541
540
|
// Schema helpers
|
|
542
541
|
// ---------------------------------------------------------------------------
|
|
543
542
|
|
|
544
|
-
/**
|
|
545
|
-
* Unwrap annotation wrappers to reach the structural schema node.
|
|
546
|
-
*/
|
|
547
|
-
function unwrapAnnotations(schema: SchemaNode): SchemaNode {
|
|
548
|
-
let s = schema
|
|
549
|
-
while (s._kind === "annotated" && s.schema !== undefined) {
|
|
550
|
-
s = s.schema
|
|
551
|
-
}
|
|
552
|
-
return s
|
|
553
|
-
}
|
|
554
|
-
|
|
555
543
|
/**
|
|
556
544
|
* Resolve the schema at a given path by walking through advanceSchema.
|
|
557
545
|
*/
|
|
@@ -567,8 +555,9 @@ function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
|
|
|
567
555
|
* Get the item schema from a sequence schema, if available.
|
|
568
556
|
*/
|
|
569
557
|
function getItemSchema(schema: SchemaNode): SchemaNode | undefined {
|
|
570
|
-
|
|
571
|
-
|
|
558
|
+
if (schema[KIND] === "sequence") return schema.item
|
|
559
|
+
if (schema[KIND] === "movable") return schema.item
|
|
560
|
+
return undefined
|
|
572
561
|
}
|
|
573
562
|
|
|
574
563
|
/**
|
|
@@ -578,12 +567,14 @@ function getFieldSchema(
|
|
|
578
567
|
schema: SchemaNode,
|
|
579
568
|
key: string,
|
|
580
569
|
): SchemaNode | undefined {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
return structural.fields[key]
|
|
570
|
+
if (schema[KIND] === "product") {
|
|
571
|
+
return schema.fields[key]
|
|
584
572
|
}
|
|
585
|
-
if (
|
|
586
|
-
return
|
|
573
|
+
if (schema[KIND] === "map") {
|
|
574
|
+
return schema.item
|
|
575
|
+
}
|
|
576
|
+
if (schema[KIND] === "set") {
|
|
577
|
+
return schema.item
|
|
587
578
|
}
|
|
588
579
|
return undefined
|
|
589
580
|
}
|
|
@@ -594,4 +585,4 @@ function getFieldSchema(
|
|
|
594
585
|
|
|
595
586
|
function pathToString(path: Path): string {
|
|
596
587
|
return path.segments.map(seg => String(seg.resolve())).join(".")
|
|
597
|
-
}
|
|
588
|
+
}
|
package/src/create.ts
CHANGED
|
@@ -21,8 +21,8 @@ import type {
|
|
|
21
21
|
SubstratePayload,
|
|
22
22
|
} from "@kyneta/schema"
|
|
23
23
|
import {
|
|
24
|
-
changefeed,
|
|
25
24
|
interpret,
|
|
25
|
+
observation,
|
|
26
26
|
readable,
|
|
27
27
|
registerSubstrate,
|
|
28
28
|
writable,
|
|
@@ -71,7 +71,7 @@ function registerDoc(
|
|
|
71
71
|
const doc: any = (interpret as any)(schema, substrate.context())
|
|
72
72
|
.with(readable)
|
|
73
73
|
.with(writable)
|
|
74
|
-
.with(
|
|
74
|
+
.with(observation)
|
|
75
75
|
.done()
|
|
76
76
|
substrates.set(doc, substrate)
|
|
77
77
|
// Also register in the general unwrap() registry so that the
|
package/src/index.ts
CHANGED
|
@@ -34,33 +34,18 @@ export {
|
|
|
34
34
|
version,
|
|
35
35
|
} from "./sync.js"
|
|
36
36
|
|
|
37
|
-
// Text annotation convenience — so users don't need LoroSchema just for text()
|
|
38
|
-
import type { AnnotatedSchema } from "@kyneta/schema"
|
|
39
|
-
import { Schema } from "@kyneta/schema"
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Collaborative text (CRDT). Produces `annotated("text")`.
|
|
43
|
-
*
|
|
44
|
-
* The annotation implies scalar string semantics for reads,
|
|
45
|
-
* but the Yjs substrate provides collaborative editing (insert, delete)
|
|
46
|
-
* via Y.Text.
|
|
47
|
-
*
|
|
48
|
-
* This is a convenience re-export so that `@kyneta/yjs-schema` users
|
|
49
|
-
* don't need to import `LoroSchema` just for `text()`.
|
|
50
|
-
*/
|
|
51
|
-
export function text(): AnnotatedSchema<"text", undefined> {
|
|
52
|
-
return Schema.annotated("text")
|
|
53
|
-
}
|
|
54
|
-
|
|
55
37
|
// Types (re-exported for convenience)
|
|
56
|
-
export type { Changeset
|
|
38
|
+
export type { Changeset } from "@kyneta/changefeed"
|
|
39
|
+
export type { Op, Ref, SubstratePayload } from "@kyneta/schema"
|
|
57
40
|
|
|
58
41
|
// ---------------------------------------------------------------------------
|
|
59
42
|
// Low-level primitives — for power users and custom substrate compositions
|
|
60
43
|
// ---------------------------------------------------------------------------
|
|
61
44
|
|
|
62
|
-
//
|
|
63
|
-
|
|
45
|
+
// Namespace — the yjs substrate namespace (replaces standalone escape hatch;
|
|
46
|
+
// the old `yjs(ref)` call is now `yjs.unwrap(ref)`)
|
|
47
|
+
export { yjs } from "./bind-yjs.js"
|
|
48
|
+
export type { YjsCaps } from "./bind-yjs.js"
|
|
64
49
|
// Change mapping
|
|
65
50
|
export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
66
51
|
// Container creation
|
|
@@ -68,10 +53,12 @@ export { ensureContainers } from "./populate.js"
|
|
|
68
53
|
// Reader
|
|
69
54
|
export { yjsReader } from "./reader.js"
|
|
70
55
|
// Substrate
|
|
71
|
-
export {
|
|
56
|
+
export {
|
|
57
|
+
createYjsSubstrate,
|
|
58
|
+
yjsReplicaFactory,
|
|
59
|
+
yjsSubstrateFactory,
|
|
60
|
+
} from "./substrate.js"
|
|
72
61
|
// Version
|
|
73
62
|
export { YjsVersion } from "./version.js"
|
|
74
|
-
// Escape hatch — access the underlying Y.Doc from a ref
|
|
75
|
-
export { yjs } from "./yjs-escape.js"
|
|
76
63
|
// Container resolution
|
|
77
|
-
export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
|
|
64
|
+
export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
|
package/src/populate.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// shared types (Y.Text, Y.Array, Y.Map) and plain value slots uniformly.
|
|
16
16
|
|
|
17
17
|
import type { Schema as SchemaNode } from "@kyneta/schema"
|
|
18
|
-
import { STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
|
|
18
|
+
import { KIND, STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
|
|
19
19
|
import * as Y from "yjs"
|
|
20
20
|
|
|
21
21
|
// ---------------------------------------------------------------------------
|
|
@@ -26,9 +26,9 @@ import * as Y from "yjs"
|
|
|
26
26
|
* Ensure that a Y.Doc's root map contains the correct Yjs shared types
|
|
27
27
|
* matching the schema structure.
|
|
28
28
|
*
|
|
29
|
-
* Obtains the root map via `doc.getMap("root")`,
|
|
30
|
-
* schema, and creates empty containers for each field within a
|
|
31
|
-
* `doc.transact()` call for atomicity.
|
|
29
|
+
* Obtains the root map via `doc.getMap("root")`, reads the root product
|
|
30
|
+
* schema's fields, and creates empty containers for each field within a
|
|
31
|
+
* single `doc.transact()` call for atomicity.
|
|
32
32
|
*
|
|
33
33
|
* When `conditional` is true, fields that already exist in the root map
|
|
34
34
|
* are skipped. This is the correct mode after hydration — containers
|
|
@@ -45,7 +45,7 @@ import * as Y from "yjs"
|
|
|
45
45
|
* structural ops across all peers, enabling Yjs deduplication on merge.
|
|
46
46
|
*
|
|
47
47
|
* @param doc - The Y.Doc to prepare
|
|
48
|
-
* @param schema - The root document schema (
|
|
48
|
+
* @param schema - The root document schema (a ProductSchema)
|
|
49
49
|
* @param conditional - If true, skip fields that already exist in the root map.
|
|
50
50
|
* Context: jj:smmulzkm (two-phase substrate construction)
|
|
51
51
|
*/
|
|
@@ -56,15 +56,7 @@ export function ensureContainers(
|
|
|
56
56
|
): void {
|
|
57
57
|
const rootMap = doc.getMap("root")
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
while (
|
|
61
|
-
rootProduct._kind === "annotated" &&
|
|
62
|
-
rootProduct.schema !== undefined
|
|
63
|
-
) {
|
|
64
|
-
rootProduct = rootProduct.schema
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (rootProduct._kind !== "product") {
|
|
59
|
+
if (schema[KIND] !== "product") {
|
|
68
60
|
return
|
|
69
61
|
}
|
|
70
62
|
|
|
@@ -75,7 +67,7 @@ export function ensureContainers(
|
|
|
75
67
|
|
|
76
68
|
try {
|
|
77
69
|
doc.transact(() => {
|
|
78
|
-
for (const [key, fieldSchema] of Object.entries(
|
|
70
|
+
for (const [key, fieldSchema] of Object.entries(schema.fields).sort(
|
|
79
71
|
([a], [b]) => a.localeCompare(b),
|
|
80
72
|
)) {
|
|
81
73
|
if (conditional && rootMap.has(key)) continue
|
|
@@ -95,62 +87,36 @@ export function ensureContainers(
|
|
|
95
87
|
/**
|
|
96
88
|
* Ensure a root-level Yjs shared type exists for a schema field.
|
|
97
89
|
*
|
|
98
|
-
* Dispatches
|
|
99
|
-
* - `
|
|
100
|
-
* - `
|
|
101
|
-
* - `
|
|
102
|
-
* - `
|
|
103
|
-
* - `
|
|
104
|
-
* - `
|
|
105
|
-
* - `map` → empty Y.Map
|
|
106
|
-
* - `scalar`/`sum` → no-op (plain values don't need containers)
|
|
90
|
+
* Dispatches on `[KIND]`:
|
|
91
|
+
* - `"text"` → empty Y.Text
|
|
92
|
+
* - `"product"` → empty Y.Map (recursive for nested products)
|
|
93
|
+
* - `"sequence"` → empty Y.Array
|
|
94
|
+
* - `"map"` → empty Y.Map
|
|
95
|
+
* - `"scalar"` / `"sum"` → Zero.structural default
|
|
96
|
+
* - `"counter"` / `"set"` / `"tree"` / `"movable"` → throw (not supported by Yjs)
|
|
107
97
|
*/
|
|
108
98
|
function ensureRootField(
|
|
109
99
|
rootMap: Y.Map<unknown>,
|
|
110
100
|
key: string,
|
|
111
101
|
fieldSchema: SchemaNode,
|
|
112
102
|
): void {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
switch (tag) {
|
|
103
|
+
switch (fieldSchema[KIND]) {
|
|
116
104
|
case "text":
|
|
117
105
|
rootMap.set(key, new Y.Text())
|
|
118
106
|
return
|
|
119
107
|
|
|
120
|
-
case "counter":
|
|
121
|
-
throw new Error(
|
|
122
|
-
`Yjs substrate does not support counter annotations. ` +
|
|
123
|
-
`Use Schema.number() with ReplaceChange instead. ` +
|
|
124
|
-
`Encountered counter annotation at root field "${key}".`,
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
case "movable":
|
|
128
|
-
throw new Error(
|
|
129
|
-
`Yjs substrate does not support movable list annotations. ` +
|
|
130
|
-
`Yjs has no native movable list type. ` +
|
|
131
|
-
`Encountered movable annotation at root field "${key}".`,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
case "tree":
|
|
135
|
-
throw new Error(
|
|
136
|
-
`Yjs substrate does not support tree annotations. ` +
|
|
137
|
-
`Yjs has no native tree type. ` +
|
|
138
|
-
`Encountered tree annotation at root field "${key}".`,
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const structural = unwrapAnnotations(fieldSchema)
|
|
143
|
-
|
|
144
|
-
switch (structural._kind) {
|
|
145
108
|
case "product":
|
|
146
|
-
rootMap.set(key, ensureMapContainers(
|
|
109
|
+
rootMap.set(key, ensureMapContainers(fieldSchema))
|
|
147
110
|
return
|
|
111
|
+
|
|
148
112
|
case "sequence":
|
|
149
113
|
rootMap.set(key, new Y.Array())
|
|
150
114
|
return
|
|
115
|
+
|
|
151
116
|
case "map":
|
|
152
117
|
rootMap.set(key, new Y.Map())
|
|
153
118
|
return
|
|
119
|
+
|
|
154
120
|
case "scalar":
|
|
155
121
|
case "sum": {
|
|
156
122
|
// Plain values don't need shared type containers, but they DO
|
|
@@ -162,6 +128,16 @@ function ensureRootField(
|
|
|
162
128
|
}
|
|
163
129
|
return
|
|
164
130
|
}
|
|
131
|
+
|
|
132
|
+
case "counter":
|
|
133
|
+
case "set":
|
|
134
|
+
case "tree":
|
|
135
|
+
case "movable":
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
|
|
138
|
+
`Supported kinds: text, product, sequence, map, scalar, sum. ` +
|
|
139
|
+
`Encountered unsupported kind at root field "${key}".`,
|
|
140
|
+
)
|
|
165
141
|
}
|
|
166
142
|
}
|
|
167
143
|
|
|
@@ -175,37 +151,33 @@ function ensureRootField(
|
|
|
175
151
|
*
|
|
176
152
|
* Only creates containers for fields that require Yjs shared types
|
|
177
153
|
* (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
|
|
178
|
-
* Scalar and sum fields are
|
|
179
|
-
* values via change() when needed.
|
|
154
|
+
* Scalar and sum fields are set to their structural zero defaults.
|
|
180
155
|
*/
|
|
181
156
|
function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
|
|
182
157
|
const map = new Y.Map()
|
|
183
|
-
const structural = unwrapAnnotations(schema)
|
|
184
158
|
|
|
185
|
-
if (
|
|
159
|
+
if (schema[KIND] !== "product") return map
|
|
186
160
|
|
|
187
161
|
for (const [key, fieldSchema] of Object.entries(
|
|
188
|
-
|
|
162
|
+
schema.fields as Record<string, SchemaNode>,
|
|
189
163
|
).sort(([a], [b]) => a.localeCompare(b))) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
continue
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const fs = unwrapAnnotations(fieldSchema)
|
|
164
|
+
switch (fieldSchema[KIND]) {
|
|
165
|
+
case "text":
|
|
166
|
+
map.set(key, new Y.Text())
|
|
167
|
+
break
|
|
198
168
|
|
|
199
|
-
switch (fs._kind) {
|
|
200
169
|
case "product":
|
|
201
170
|
map.set(key, ensureMapContainers(fieldSchema))
|
|
202
171
|
break
|
|
172
|
+
|
|
203
173
|
case "sequence":
|
|
204
174
|
map.set(key, new Y.Array())
|
|
205
175
|
break
|
|
176
|
+
|
|
206
177
|
case "map":
|
|
207
178
|
map.set(key, new Y.Map())
|
|
208
179
|
break
|
|
180
|
+
|
|
209
181
|
case "scalar":
|
|
210
182
|
case "sum": {
|
|
211
183
|
const zero = Zero.structural(fieldSchema)
|
|
@@ -214,23 +186,18 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
|
|
|
214
186
|
}
|
|
215
187
|
break
|
|
216
188
|
}
|
|
189
|
+
|
|
190
|
+
case "counter":
|
|
191
|
+
case "set":
|
|
192
|
+
case "tree":
|
|
193
|
+
case "movable":
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
|
|
196
|
+
`Supported kinds: text, product, sequence, map, scalar, sum. ` +
|
|
197
|
+
`Encountered unsupported kind at nested field "${key}".`,
|
|
198
|
+
)
|
|
217
199
|
}
|
|
218
200
|
}
|
|
219
201
|
|
|
220
202
|
return map
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
// Helpers
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Unwrap annotation wrappers to reach the structural schema node.
|
|
229
|
-
*/
|
|
230
|
-
function unwrapAnnotations(schema: SchemaNode): SchemaNode {
|
|
231
|
-
let s = schema
|
|
232
|
-
while (s._kind === "annotated" && s.schema !== undefined) {
|
|
233
|
-
s = s.schema
|
|
234
|
-
}
|
|
235
|
-
return s
|
|
236
|
-
}
|
|
203
|
+
}
|
package/src/substrate.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function createYjsSubstrate(
|
|
|
101
101
|
// wrappedPrepare (changefeed layer) still buffers the op.
|
|
102
102
|
},
|
|
103
103
|
|
|
104
|
-
onFlush(
|
|
104
|
+
onFlush(_origin?: string): void {
|
|
105
105
|
if (!inOurTransaction && pendingChanges.length > 0) {
|
|
106
106
|
// Local write: apply accumulated changes within a single
|
|
107
107
|
// Yjs transaction tagged with our origin for echo suppression.
|
|
@@ -132,6 +132,18 @@ export function createYjsSubstrate(
|
|
|
132
132
|
return new YjsVersion(Y.encodeStateVector(doc))
|
|
133
133
|
},
|
|
134
134
|
|
|
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
|
+
|
|
135
147
|
exportEntirety(): SubstratePayload {
|
|
136
148
|
return {
|
|
137
149
|
kind: "entirety",
|
|
@@ -180,7 +192,7 @@ export function createYjsSubstrate(
|
|
|
180
192
|
}
|
|
181
193
|
|
|
182
194
|
// Convert Yjs events → kyneta Ops
|
|
183
|
-
const ops = eventsToOps(events)
|
|
195
|
+
const ops = eventsToOps(events, schema)
|
|
184
196
|
if (ops.length === 0) {
|
|
185
197
|
return
|
|
186
198
|
}
|
|
@@ -239,24 +251,57 @@ export function createYjsSubstrate(
|
|
|
239
251
|
* storage without ever interpreting document fields.
|
|
240
252
|
*/
|
|
241
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
|
+
|
|
242
259
|
return {
|
|
243
|
-
[BACKING_DOC]
|
|
260
|
+
get [BACKING_DOC]() {
|
|
261
|
+
return currentDoc
|
|
262
|
+
},
|
|
244
263
|
|
|
245
264
|
version(): YjsVersion {
|
|
246
|
-
return new YjsVersion(Y.encodeStateVector(
|
|
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))
|
|
247
292
|
},
|
|
248
293
|
|
|
249
294
|
exportEntirety(): SubstratePayload {
|
|
250
295
|
return {
|
|
251
296
|
kind: "entirety",
|
|
252
297
|
encoding: "binary",
|
|
253
|
-
data: Y.encodeStateAsUpdate(
|
|
298
|
+
data: Y.encodeStateAsUpdate(currentDoc),
|
|
254
299
|
}
|
|
255
300
|
},
|
|
256
301
|
|
|
257
302
|
exportSince(since: YjsVersion): SubstratePayload | null {
|
|
258
303
|
try {
|
|
259
|
-
const bytes = Y.encodeStateAsUpdate(
|
|
304
|
+
const bytes = Y.encodeStateAsUpdate(currentDoc, since.sv)
|
|
260
305
|
return { kind: "since", encoding: "binary", data: bytes }
|
|
261
306
|
} catch {
|
|
262
307
|
return null
|
|
@@ -273,7 +318,7 @@ export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
|
|
|
273
318
|
"If you recently switched CRDT backends, stale clients may be sending incompatible data.",
|
|
274
319
|
)
|
|
275
320
|
}
|
|
276
|
-
Y.applyUpdate(
|
|
321
|
+
Y.applyUpdate(currentDoc, payload.data)
|
|
277
322
|
},
|
|
278
323
|
} as Replica<YjsVersion>
|
|
279
324
|
}
|