@kyneta/yjs-schema 1.6.1 → 1.7.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 +17 -61
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +135 -178
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/__tests__/materialize.test.ts +227 -0
- package/src/__tests__/position.test.ts +7 -7
- package/src/__tests__/structural-merge.test.ts +18 -18
- package/src/__tests__/substrate.test.ts +1 -3
- package/src/bind-yjs.ts +3 -5
- package/src/change-mapping.ts +62 -35
- package/src/index.ts +1 -2
- package/src/materialize.ts +109 -0
- package/src/populate.ts +23 -37
- package/src/substrate.ts +27 -14
- package/src/yjs-extract.ts +52 -0
- package/src/yjs-resolve.ts +30 -95
- package/src/__tests__/reader.test.ts +0 -685
- package/src/reader.ts +0 -174
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// materialize — Yjs→PlainState materialization via generic resolver.
|
|
2
|
+
//
|
|
3
|
+
// Implements `createYjsResolver`, a closure-based `MaterializeResolver`
|
|
4
|
+
// that navigates the Yjs shared type tree via `resolveYjsType`. The
|
|
5
|
+
// generic `createMaterializeInterpreter` drives the catamorphism; the
|
|
6
|
+
// resolver handles only the CRDT-specific value extraction.
|
|
7
|
+
//
|
|
8
|
+
// Unsupported types (counter, tree, movable) return `undefined` from
|
|
9
|
+
// the resolver, triggering the generic interpreter's zero fallback.
|
|
10
|
+
//
|
|
11
|
+
// Zero fallback for missing values is handled canonically by the
|
|
12
|
+
// generic interpreter — not inlined here.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
MaterializeResolver,
|
|
16
|
+
Path,
|
|
17
|
+
PlainState,
|
|
18
|
+
RichTextDelta,
|
|
19
|
+
SchemaBinding,
|
|
20
|
+
Schema as SchemaNode,
|
|
21
|
+
} from "@kyneta/schema"
|
|
22
|
+
import {
|
|
23
|
+
createMaterializeInterpreter,
|
|
24
|
+
interpret,
|
|
25
|
+
isNonNullObject,
|
|
26
|
+
materializeContextFromResolver,
|
|
27
|
+
} from "@kyneta/schema"
|
|
28
|
+
import * as Y from "yjs"
|
|
29
|
+
import { extractValue, yTextToRichTextDelta } from "./yjs-extract.js"
|
|
30
|
+
import { resolveYjsType } from "./yjs-resolve.js"
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Yjs resolver
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function createYjsResolver(
|
|
37
|
+
rootMap: Y.Map<any>,
|
|
38
|
+
rootSchema: SchemaNode,
|
|
39
|
+
binding?: SchemaBinding,
|
|
40
|
+
): MaterializeResolver {
|
|
41
|
+
return {
|
|
42
|
+
resolveValue(path: Path): unknown {
|
|
43
|
+
const result = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
44
|
+
return extractValue(result.resolved)
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
resolveText(path: Path): string | undefined {
|
|
48
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
49
|
+
if (resolved instanceof Y.Text) {
|
|
50
|
+
return resolved.toJSON()
|
|
51
|
+
}
|
|
52
|
+
const value = extractValue(resolved)
|
|
53
|
+
return typeof value === "string" ? value : undefined
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Yjs does not support counters — schemas with counter types are
|
|
57
|
+
// rejected at bind time. Return undefined to trigger zero fallback.
|
|
58
|
+
resolveCounter(_path: Path): number | undefined {
|
|
59
|
+
return undefined
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
resolveRichText(path: Path): RichTextDelta | undefined {
|
|
63
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
64
|
+
if (resolved instanceof Y.Text) {
|
|
65
|
+
return yTextToRichTextDelta(resolved)
|
|
66
|
+
}
|
|
67
|
+
return undefined
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
resolveLength(path: Path): number {
|
|
71
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
72
|
+
if (resolved instanceof Y.Array) {
|
|
73
|
+
return resolved.length
|
|
74
|
+
}
|
|
75
|
+
return Array.isArray(resolved) ? resolved.length : 0
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
resolveKeys(path: Path): string[] {
|
|
79
|
+
const { resolved } = resolveYjsType(rootMap, rootSchema, path, binding)
|
|
80
|
+
if (resolved instanceof Y.Map) {
|
|
81
|
+
return Array.from(resolved.keys())
|
|
82
|
+
}
|
|
83
|
+
return isNonNullObject(resolved) ? Object.keys(resolved) : []
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// Yjs has no tree primitive — schemas with `Schema.tree` are rejected
|
|
87
|
+
// at bind time. Defensive [] for any caller that reaches here.
|
|
88
|
+
resolveForest(_path: Path): readonly never[] {
|
|
89
|
+
return []
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Public API
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export function materializeYjsShadow(
|
|
99
|
+
doc: Y.Doc,
|
|
100
|
+
schema: SchemaNode,
|
|
101
|
+
binding?: SchemaBinding,
|
|
102
|
+
): PlainState {
|
|
103
|
+
const rootMap = doc.getMap("root")
|
|
104
|
+
const resolver = createYjsResolver(rootMap, schema, binding)
|
|
105
|
+
const interp = createMaterializeInterpreter(resolver)
|
|
106
|
+
const ctx = materializeContextFromResolver(resolver)
|
|
107
|
+
const result = interpret(schema, interp, ctx)
|
|
108
|
+
return result as PlainState
|
|
109
|
+
}
|
package/src/populate.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// populate — Yjs container creation from schema structure.
|
|
2
2
|
//
|
|
3
3
|
// Ensures that the correct Yjs shared types (Y.Text, Y.Array, Y.Map)
|
|
4
|
-
// exist in a Y.Doc's root map to match the schema structure
|
|
5
|
-
// scalar/sum fields are initialized with Zero.structural defaults.
|
|
4
|
+
// exist in a Y.Doc's root map to match the schema structure.
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// without this, unset scalars would return undefined instead of their
|
|
11
|
-
// type-correct zero ("", 0, false).
|
|
6
|
+
// Only container types (text, product, sequence, map) require CRDT
|
|
7
|
+
// writes here. Scalar and sum fields are handled by the materializer's
|
|
8
|
+
// zero fallback — no Yjs writes are needed for non-container types.
|
|
12
9
|
//
|
|
13
10
|
// Root container strategy: All schema fields are children of a single
|
|
14
11
|
// root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
|
|
@@ -20,7 +17,7 @@
|
|
|
20
17
|
// functions: ensureContainers, ensureRootField, ensureMapContainers.
|
|
21
18
|
|
|
22
19
|
import type { SchemaBinding, Schema as SchemaNode } from "@kyneta/schema"
|
|
23
|
-
import { KIND, STRUCTURAL_YJS_CLIENT_ID
|
|
20
|
+
import { KIND, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
|
|
24
21
|
import * as Y from "yjs"
|
|
25
22
|
|
|
26
23
|
// ---------------------------------------------------------------------------
|
|
@@ -35,14 +32,10 @@ import * as Y from "yjs"
|
|
|
35
32
|
* schema's fields, and creates empty containers for each field within a
|
|
36
33
|
* single `doc.transact()` call for atomicity.
|
|
37
34
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* with stored operations).
|
|
43
|
-
*
|
|
44
|
-
* When `conditional` is false (default), all fields are created
|
|
45
|
-
* unconditionally. This is the correct mode for fresh documents.
|
|
35
|
+
* Container fields (text, product, sequence, map) are created if absent;
|
|
36
|
+
* existing containers are preserved (calling `rootMap.set()` on a field
|
|
37
|
+
* that already exists would be a destructive CRDT write). Scalar and sum
|
|
38
|
+
* fields are no-ops — the materializer handles zeros.
|
|
46
39
|
*
|
|
47
40
|
* **Structural identity:** This function temporarily sets `doc.clientID`
|
|
48
41
|
* to `STRUCTURAL_YJS_CLIENT_ID` (0) for the duration of container creation,
|
|
@@ -56,14 +49,11 @@ import * as Y from "yjs"
|
|
|
56
49
|
*
|
|
57
50
|
* @param doc - The Y.Doc to prepare
|
|
58
51
|
* @param schema - The root document schema (a ProductSchema)
|
|
59
|
-
* @param conditional - If true, skip fields that already exist in the root map.
|
|
60
|
-
* Context: jj:smmulzkm (two-phase substrate construction)
|
|
61
52
|
* @param binding - Optional SchemaBinding for identity-keyed containers.
|
|
62
53
|
*/
|
|
63
54
|
export function ensureContainers(
|
|
64
55
|
doc: Y.Doc,
|
|
65
56
|
schema: SchemaNode,
|
|
66
|
-
conditional = false,
|
|
67
57
|
binding?: SchemaBinding,
|
|
68
58
|
): void {
|
|
69
59
|
const rootMap = doc.getMap("root")
|
|
@@ -84,7 +74,6 @@ export function ensureContainers(
|
|
|
84
74
|
)) {
|
|
85
75
|
const identity = binding?.forward.get(key) as string | undefined
|
|
86
76
|
const mapKey = identity ?? key
|
|
87
|
-
if (conditional && rootMap.has(mapKey)) continue
|
|
88
77
|
ensureRootField(
|
|
89
78
|
rootMap,
|
|
90
79
|
mapKey,
|
|
@@ -112,7 +101,7 @@ export function ensureContainers(
|
|
|
112
101
|
* - `"product"` → empty Y.Map (recursive for nested products)
|
|
113
102
|
* - `"sequence"` → empty Y.Array
|
|
114
103
|
* - `"map"` → empty Y.Map
|
|
115
|
-
* - `"scalar"` / `"sum"` →
|
|
104
|
+
* - `"scalar"` / `"sum"` → no-op (materializer zero fallback)
|
|
116
105
|
* - `"counter"` / `"set"` / `"tree"` / `"movable"` → throw (not supported by Yjs)
|
|
117
106
|
*
|
|
118
107
|
* @param rootMap - The root Y.Map to set the field on.
|
|
@@ -128,6 +117,12 @@ function ensureRootField(
|
|
|
128
117
|
binding?: SchemaBinding,
|
|
129
118
|
prefix?: string,
|
|
130
119
|
): void {
|
|
120
|
+
// Skip fields that already exist — calling rootMap.set() on an existing
|
|
121
|
+
// shared type would replace it (a destructive CRDT write), and scalars
|
|
122
|
+
// are no-ops regardless. This is safe on fresh docs (nothing to skip)
|
|
123
|
+
// and necessary on hydrated docs (preserves existing data).
|
|
124
|
+
if (rootMap.has(key)) return
|
|
125
|
+
|
|
131
126
|
switch (fieldSchema[KIND]) {
|
|
132
127
|
case "text":
|
|
133
128
|
case "richtext":
|
|
@@ -147,16 +142,10 @@ function ensureRootField(
|
|
|
147
142
|
return
|
|
148
143
|
|
|
149
144
|
case "scalar":
|
|
150
|
-
case "sum":
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// type-correct values (e.g. "" not undefined for strings).
|
|
154
|
-
const zero = Zero.structural(fieldSchema)
|
|
155
|
-
if (zero !== undefined) {
|
|
156
|
-
rootMap.set(key, zero)
|
|
157
|
-
}
|
|
145
|
+
case "sum":
|
|
146
|
+
// Value concerns are handled by the materializer's zero fallback.
|
|
147
|
+
// No CRDT writes needed for non-container types.
|
|
158
148
|
return
|
|
159
|
-
}
|
|
160
149
|
|
|
161
150
|
case "counter":
|
|
162
151
|
case "set":
|
|
@@ -180,7 +169,7 @@ function ensureRootField(
|
|
|
180
169
|
*
|
|
181
170
|
* Only creates containers for fields that require Yjs shared types
|
|
182
171
|
* (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
|
|
183
|
-
* Scalar and sum fields are
|
|
172
|
+
* Scalar and sum fields are skipped (materializer zero fallback).
|
|
184
173
|
*
|
|
185
174
|
* **Identity-keying:** When a `binding` is provided, computes the
|
|
186
175
|
* absolute schema path for each nested field (`prefix.fieldName`) and
|
|
@@ -226,13 +215,10 @@ function ensureMapContainers(
|
|
|
226
215
|
break
|
|
227
216
|
|
|
228
217
|
case "scalar":
|
|
229
|
-
case "sum":
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
map.set(mapKey, zero)
|
|
233
|
-
}
|
|
218
|
+
case "sum":
|
|
219
|
+
// Value concerns are handled by the materializer's zero fallback.
|
|
220
|
+
// No CRDT writes needed for non-container types.
|
|
234
221
|
break
|
|
235
|
-
}
|
|
236
222
|
|
|
237
223
|
case "counter":
|
|
238
224
|
case "set":
|
package/src/substrate.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
BatchOptions,
|
|
27
27
|
ChangeBase,
|
|
28
28
|
Path,
|
|
29
|
+
PlainState,
|
|
29
30
|
PositionCapable,
|
|
30
31
|
ProductSchema,
|
|
31
32
|
Reader,
|
|
@@ -41,17 +42,19 @@ import type {
|
|
|
41
42
|
WritableContext,
|
|
42
43
|
} from "@kyneta/schema"
|
|
43
44
|
import {
|
|
45
|
+
applyChange,
|
|
44
46
|
BACKING_DOC,
|
|
45
47
|
buildWritableContext,
|
|
46
48
|
deriveSchemaBinding,
|
|
47
49
|
executeBatch,
|
|
48
50
|
KIND,
|
|
51
|
+
plainReader,
|
|
49
52
|
} from "@kyneta/schema"
|
|
50
53
|
import * as Y from "yjs"
|
|
51
54
|
import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
55
|
+
import { materializeYjsShadow } from "./materialize.js"
|
|
52
56
|
import { ensureContainers } from "./populate.js"
|
|
53
57
|
import { toYjsAssoc, YjsPosition } from "./position.js"
|
|
54
|
-
import { yjsReader } from "./reader.js"
|
|
55
58
|
import { YjsVersion } from "./version.js"
|
|
56
59
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
57
60
|
|
|
@@ -111,8 +114,10 @@ export function createYjsSubstrate(
|
|
|
111
114
|
// The root Y.Map — all schema fields are children of this single map.
|
|
112
115
|
const rootMap = doc.getMap("root")
|
|
113
116
|
|
|
114
|
-
// The
|
|
115
|
-
|
|
117
|
+
// The shadow — a plain JS object materialized from the Y.Doc.
|
|
118
|
+
// Kept in sync by applyChange() in prepare().
|
|
119
|
+
const shadow: PlainState = materializeYjsShadow(doc, schema, binding)
|
|
120
|
+
const reader: Reader = plainReader(shadow)
|
|
116
121
|
|
|
117
122
|
// --- Substrate object ---
|
|
118
123
|
|
|
@@ -122,20 +127,31 @@ export function createYjsSubstrate(
|
|
|
122
127
|
reader: reader,
|
|
123
128
|
|
|
124
129
|
prepare(path: Path, change: ChangeBase, options?: BatchOptions): void {
|
|
130
|
+
// Local writes: apply eagerly to the shadow so reads are
|
|
131
|
+
// immediately consistent. Replay writes: skip — the shadow
|
|
132
|
+
// will be re-materialized from the Y.Doc in onFlush(replay).
|
|
133
|
+
if (!options?.replay) {
|
|
134
|
+
applyChange(shadow, path, change)
|
|
135
|
+
}
|
|
136
|
+
|
|
125
137
|
if (options?.replay) {
|
|
126
|
-
// Yjs already has these ops (the bridge is replaying them
|
|
127
|
-
// through kyneta solely so the changefeed layer can deliver
|
|
128
|
-
// notifications — wrappedPrepare buffered the op upstream).
|
|
129
138
|
return
|
|
130
139
|
}
|
|
131
|
-
// Mutations happen in onFlush inside a single Yjs transaction.
|
|
132
140
|
pendingChanges.push({ path, change })
|
|
133
141
|
},
|
|
134
142
|
|
|
135
143
|
onFlush(options?: BatchOptions): void {
|
|
136
144
|
if (options?.replay) {
|
|
137
|
-
//
|
|
138
|
-
|
|
145
|
+
// Re-materialize shadow from the Y.Doc (already committed).
|
|
146
|
+
const fresh = materializeYjsShadow(doc, schema, binding)
|
|
147
|
+
for (const key of Object.keys(fresh)) {
|
|
148
|
+
shadow[key] = fresh[key]
|
|
149
|
+
}
|
|
150
|
+
for (const key of Object.keys(shadow)) {
|
|
151
|
+
if (!(key in fresh)) {
|
|
152
|
+
delete shadow[key]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
139
155
|
return
|
|
140
156
|
}
|
|
141
157
|
if (pendingChanges.length === 0) return
|
|
@@ -474,17 +490,14 @@ export const yjsSubstrateFactory: SubstrateFactory<YjsVersion> = {
|
|
|
474
490
|
const doc = (replica as any)[BACKING_DOC] as Y.Doc
|
|
475
491
|
const binding = trivialBinding(schema)
|
|
476
492
|
// No identity injection for the standalone factory (no peerId).
|
|
477
|
-
|
|
478
|
-
// from hydrated state.
|
|
479
|
-
ensureContainers(doc, schema, true, binding)
|
|
493
|
+
ensureContainers(doc, schema, binding)
|
|
480
494
|
return createYjsSubstrate(doc, schema, binding)
|
|
481
495
|
},
|
|
482
496
|
|
|
483
497
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
484
|
-
// Fresh doc — unconditional ensureContainers (nothing to conflict with).
|
|
485
498
|
const doc = new Y.Doc()
|
|
486
499
|
const binding = trivialBinding(schema)
|
|
487
|
-
ensureContainers(doc, schema,
|
|
500
|
+
ensureContainers(doc, schema, binding)
|
|
488
501
|
return createYjsSubstrate(doc, schema, binding)
|
|
489
502
|
},
|
|
490
503
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// yjs-extract — shared value-extraction helpers for Yjs shared types.
|
|
2
|
+
//
|
|
3
|
+
// These functions are used by both the reader (yjsReader) and the
|
|
4
|
+
// materialize interpreter to convert Yjs shared types into plain values.
|
|
5
|
+
|
|
6
|
+
import type { RichTextDelta, RichTextSpan } from "@kyneta/schema"
|
|
7
|
+
import * as Y from "yjs"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a plain value from a Yjs shared type or return a plain value as-is.
|
|
11
|
+
*
|
|
12
|
+
* - Y.Text → `.toJSON()` (string)
|
|
13
|
+
* - Y.Map → `.toJSON()` (plain object snapshot — for product/map reads)
|
|
14
|
+
* - Y.Array → `.toJSON()` (plain array snapshot)
|
|
15
|
+
* - Plain values (string, number, boolean, null) → returned as-is
|
|
16
|
+
*/
|
|
17
|
+
export function extractValue(resolved: unknown): unknown {
|
|
18
|
+
if (resolved instanceof Y.Text) {
|
|
19
|
+
return resolved.toJSON()
|
|
20
|
+
}
|
|
21
|
+
if (resolved instanceof Y.Map) {
|
|
22
|
+
return resolved.toJSON()
|
|
23
|
+
}
|
|
24
|
+
if (resolved instanceof Y.Array) {
|
|
25
|
+
return resolved.toJSON()
|
|
26
|
+
}
|
|
27
|
+
// Plain scalar value (string, number, boolean, null, etc.)
|
|
28
|
+
return resolved
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a Y.Text's delta (Quill format) to a kyneta RichTextDelta.
|
|
33
|
+
*
|
|
34
|
+
* Yjs `.toDelta()` returns `{ insert: string, attributes?: Record<string, any> }[]`.
|
|
35
|
+
* Kyneta RichTextDelta is `{ text: string, marks?: MarkMap }[]`.
|
|
36
|
+
*/
|
|
37
|
+
export function yTextToRichTextDelta(ytext: Y.Text): RichTextDelta {
|
|
38
|
+
const delta = ytext.toDelta() as Array<{
|
|
39
|
+
insert: string
|
|
40
|
+
attributes?: Record<string, unknown>
|
|
41
|
+
}>
|
|
42
|
+
const spans: RichTextSpan[] = []
|
|
43
|
+
for (const d of delta) {
|
|
44
|
+
if (typeof d.insert !== "string") continue
|
|
45
|
+
const span: RichTextSpan =
|
|
46
|
+
d.attributes && Object.keys(d.attributes).length > 0
|
|
47
|
+
? { text: d.insert, marks: d.attributes }
|
|
48
|
+
: { text: d.insert }
|
|
49
|
+
spans.push(span)
|
|
50
|
+
}
|
|
51
|
+
return spans
|
|
52
|
+
}
|
package/src/yjs-resolve.ts
CHANGED
|
@@ -1,35 +1,29 @@
|
|
|
1
1
|
// yjs-resolve — Yjs-specific path resolution.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// resolveContainer works for Loro — but uses `instanceof` for
|
|
9
|
-
// runtime type discrimination instead of Loro's `.kind()` method.
|
|
3
|
+
// `stepIntoYjs` is the per-step substrate dispatch; `resolveYjsType`
|
|
4
|
+
// applies the core `foldPath` primitive (from `@kyneta/schema`) around
|
|
5
|
+
// it. The semantic invariants of the fold — identity-keying at
|
|
6
|
+
// product-field boundaries, sum-boundary short-circuit — live in
|
|
7
|
+
// `fold-path.ts`, not here.
|
|
10
8
|
//
|
|
11
9
|
// Root container strategy: All schema fields are children of a single
|
|
12
10
|
// root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
|
|
13
11
|
// shared types (Y.Text, Y.Array, Y.Map) and plain values uniformly.
|
|
14
12
|
// Using a single root Y.Map enables one `observeDeep` call that
|
|
15
13
|
// captures all mutations with correct relative paths.
|
|
16
|
-
//
|
|
17
|
-
// Identity-keying: when a SchemaBinding is provided, every product-field
|
|
18
|
-
// boundary uses the identity hash (from binding.forward) instead of the
|
|
19
|
-
// field name as the Y.Map key. The binding is threaded through
|
|
20
|
-
// resolveYjsType and stepIntoYjs.
|
|
21
14
|
|
|
22
|
-
import
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
15
|
+
import {
|
|
16
|
+
foldPath,
|
|
17
|
+
type Path,
|
|
18
|
+
type PathFoldResult,
|
|
19
|
+
type PathStepper,
|
|
20
|
+
type SchemaBinding,
|
|
21
|
+
type Schema as SchemaNode,
|
|
27
22
|
} from "@kyneta/schema"
|
|
28
|
-
import { advanceSchema, KIND } from "@kyneta/schema"
|
|
29
23
|
import * as Y from "yjs"
|
|
30
24
|
|
|
31
25
|
// ---------------------------------------------------------------------------
|
|
32
|
-
// stepIntoYjs —
|
|
26
|
+
// stepIntoYjs — per-step substrate dispatch (PathStepper for Yjs)
|
|
33
27
|
// ---------------------------------------------------------------------------
|
|
34
28
|
|
|
35
29
|
/**
|
|
@@ -41,15 +35,16 @@ import * as Y from "yjs"
|
|
|
41
35
|
* - `Y.Text` → terminal (cannot step further)
|
|
42
36
|
* - Plain value → terminal (return `undefined`)
|
|
43
37
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
38
|
+
* `_nextSchema` is part of the `PathStepper` contract for Loro's root
|
|
39
|
+
* dispatch but is unused here — Yjs's `instanceof` dispatch doesn't
|
|
40
|
+
* need to look ahead at the next schema kind.
|
|
47
41
|
*/
|
|
48
|
-
export
|
|
49
|
-
current
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
export const stepIntoYjs: PathStepper = (
|
|
43
|
+
current,
|
|
44
|
+
_nextSchema,
|
|
45
|
+
segment,
|
|
46
|
+
identity,
|
|
47
|
+
) => {
|
|
53
48
|
const resolved = segment.resolve()
|
|
54
49
|
|
|
55
50
|
if (current instanceof Y.Map) {
|
|
@@ -69,85 +64,25 @@ export function stepIntoYjs(
|
|
|
69
64
|
}
|
|
70
65
|
|
|
71
66
|
// ---------------------------------------------------------------------------
|
|
72
|
-
// resolveYjsType — full path resolution via
|
|
67
|
+
// resolveYjsType — full path resolution via foldPath
|
|
73
68
|
// ---------------------------------------------------------------------------
|
|
74
69
|
|
|
75
|
-
/**
|
|
76
|
-
* Result of resolving a Yjs shared type at a path.
|
|
77
|
-
*
|
|
78
|
-
* Includes both the resolved Yjs value and the schema at that position,
|
|
79
|
-
* enabling callers to distinguish between schema kinds that map to the
|
|
80
|
-
* same Yjs type (e.g. "text" vs "richtext" both use Y.Text).
|
|
81
|
-
*/
|
|
82
|
-
export interface ResolvedYjs {
|
|
83
|
-
readonly resolved: unknown
|
|
84
|
-
readonly schema: SchemaNode
|
|
85
|
-
}
|
|
86
|
-
|
|
87
70
|
/**
|
|
88
71
|
* Resolve a Yjs shared type (or plain value) at the given path.
|
|
89
72
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
73
|
+
* Thin wrapper around `foldPath(stepIntoYjs, ...)`. Returns the
|
|
74
|
+
* `PathFoldResult` shape from core — `{ resolved, schema }`.
|
|
92
75
|
*
|
|
93
|
-
* When a `binding` is provided,
|
|
94
|
-
*
|
|
95
|
-
* identity hash is used instead of the field name at every product-field
|
|
96
|
-
* boundary (root and nested).
|
|
76
|
+
* When a `binding` is provided, every product-field boundary uses the
|
|
77
|
+
* identity hash from `binding.forward` instead of the field name.
|
|
97
78
|
*
|
|
98
|
-
*
|
|
99
|
-
* the terminal position. For an empty path, returns the root map and
|
|
100
|
-
* root schema.
|
|
101
|
-
*
|
|
102
|
-
* @param rootMap - The root `Y.Map` obtained via `doc.getMap("root")`
|
|
103
|
-
* @param rootSchema - The root document schema
|
|
104
|
-
* @param path - The path to resolve
|
|
105
|
-
* @param binding - Optional SchemaBinding for identity-keyed navigation.
|
|
79
|
+
* For an empty path, returns the root map and root schema.
|
|
106
80
|
*/
|
|
107
81
|
export function resolveYjsType(
|
|
108
82
|
rootMap: Y.Map<any>,
|
|
109
83
|
rootSchema: SchemaNode,
|
|
110
84
|
path: Path,
|
|
111
85
|
binding?: SchemaBinding,
|
|
112
|
-
):
|
|
113
|
-
|
|
114
|
-
let schema = rootSchema
|
|
115
|
-
// Track the accumulated absolute schema path for identity lookup.
|
|
116
|
-
// Only string (key) segments contribute — index segments are structural
|
|
117
|
-
// and don't participate in identity-keying.
|
|
118
|
-
let absPath = ""
|
|
119
|
-
|
|
120
|
-
for (let i = 0; i < path.length; i++) {
|
|
121
|
-
const seg = path.segments[i]
|
|
122
|
-
if (!seg) throw new Error(`Missing segment at index ${i}`)
|
|
123
|
-
const nextSchema = advanceSchema(schema, seg)
|
|
124
|
-
|
|
125
|
-
// Compute identity for this step if binding is provided and the
|
|
126
|
-
// segment is a key (field name at a product boundary).
|
|
127
|
-
let identity: string | undefined
|
|
128
|
-
if (binding && seg.role === "key") {
|
|
129
|
-
const segStr = seg.resolve() as string
|
|
130
|
-
absPath = absPath ? `${absPath}.${segStr}` : segStr
|
|
131
|
-
identity = binding.forward.get(absPath) as string | undefined
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
current = stepIntoYjs(current, seg, identity)
|
|
135
|
-
schema = nextSchema
|
|
136
|
-
|
|
137
|
-
// Sum variants are always PlainSchema — no CRDT containers inside.
|
|
138
|
-
// Once we land on a sum, resolve remaining segments via plain JS
|
|
139
|
-
// property access on the (JSON) value.
|
|
140
|
-
if (schema[KIND] === "sum" && i + 1 < path.length) {
|
|
141
|
-
for (let j = i + 1; j < path.length; j++) {
|
|
142
|
-
const remaining = path.segments[j]
|
|
143
|
-
if (!remaining) throw new Error(`Missing segment at index ${j}`)
|
|
144
|
-
current = (current as Record<string, unknown>)?.[
|
|
145
|
-
remaining.resolve() as string
|
|
146
|
-
]
|
|
147
|
-
}
|
|
148
|
-
return { resolved: current, schema }
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return { resolved: current, schema }
|
|
86
|
+
): PathFoldResult {
|
|
87
|
+
return foldPath(rootMap, rootSchema, path, stepIntoYjs, binding)
|
|
153
88
|
}
|