@kyneta/yjs-schema 1.6.0 → 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 +151 -202
- package/dist/index.js.map +1 -1
- package/package.json +6 -8
- package/src/__tests__/create.test.ts +1 -1
- package/src/__tests__/materialize.test.ts +227 -0
- package/src/__tests__/position.test.ts +7 -7
- package/src/__tests__/record-text-spike.test.ts +5 -5
- package/src/__tests__/structural-merge.test.ts +20 -20
- package/src/__tests__/substrate.test.ts +45 -0
- 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 +62 -52
- 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
package/src/change-mapping.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
MapChange,
|
|
23
23
|
Op,
|
|
24
24
|
Path,
|
|
25
|
+
ProductSchema,
|
|
25
26
|
ReplaceChange,
|
|
26
27
|
RichTextChange,
|
|
27
28
|
RichTextInstruction,
|
|
@@ -33,9 +34,9 @@ import type {
|
|
|
33
34
|
TextInstruction,
|
|
34
35
|
} from "@kyneta/schema"
|
|
35
36
|
import {
|
|
36
|
-
advanceSchema,
|
|
37
37
|
expandMapOpsToLeaves,
|
|
38
38
|
KIND,
|
|
39
|
+
pathSchema,
|
|
39
40
|
RawPath,
|
|
40
41
|
richTextChange,
|
|
41
42
|
} from "@kyneta/schema"
|
|
@@ -118,6 +119,17 @@ export function applyChangeToYjs(
|
|
|
118
119
|
`Attempted TreeChange at path [${pathToString(path)}].`,
|
|
119
120
|
)
|
|
120
121
|
|
|
122
|
+
case "set-op":
|
|
123
|
+
// Sets (`Schema.set`) are rejected by `yjs.bind` at compile time
|
|
124
|
+
// (`"add-wins-per-key"` is not in `YjsLaws`). Unreachable from any
|
|
125
|
+
// bound Yjs substrate today; kept against the new `SetChange`
|
|
126
|
+
// vocabulary so future law-set expansion has a clear extension point.
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
129
|
+
`Schema.set requires "add-wins-per-key" which is not in YjsLaws. ` +
|
|
130
|
+
`Attempted SetChange at path [${pathToString(path)}].`,
|
|
131
|
+
)
|
|
132
|
+
|
|
121
133
|
default:
|
|
122
134
|
throw new Error(
|
|
123
135
|
`applyChangeToYjs: unsupported change type "${change.type}"`,
|
|
@@ -201,7 +213,7 @@ function applySequenceChange(
|
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
// Resolve the item schema for structured insert detection
|
|
204
|
-
const targetSchema =
|
|
216
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
205
217
|
const itemSchema = getItemSchema(targetSchema)
|
|
206
218
|
|
|
207
219
|
let cursor = 0
|
|
@@ -241,7 +253,7 @@ function applyMapChange(
|
|
|
241
253
|
}
|
|
242
254
|
|
|
243
255
|
// Resolve the schema at this path for structured value detection
|
|
244
|
-
const targetSchema =
|
|
256
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
245
257
|
|
|
246
258
|
// Apply deletes first
|
|
247
259
|
if (change.delete) {
|
|
@@ -259,9 +271,10 @@ function applyMapChange(
|
|
|
259
271
|
// For map schemas (records), use the key as-is (no identity-keying).
|
|
260
272
|
let mapKey = key
|
|
261
273
|
if (binding && targetSchema[KIND] === "product") {
|
|
262
|
-
// Compute absolute schema path for this field
|
|
274
|
+
// Compute absolute schema path for this field — only product-field
|
|
275
|
+
// segments contribute (entry segments are runtime keys).
|
|
263
276
|
const parentAbsPath = path.segments
|
|
264
|
-
.filter(s => s.role === "
|
|
277
|
+
.filter(s => s.role === "field")
|
|
265
278
|
.map(s => s.resolve() as string)
|
|
266
279
|
.join(".")
|
|
267
280
|
const absPath = parentAbsPath ? `${parentAbsPath}.${key}` : key
|
|
@@ -303,15 +316,19 @@ function applyReplaceChange(
|
|
|
303
316
|
)
|
|
304
317
|
|
|
305
318
|
const resolved = lastSeg.resolve()
|
|
306
|
-
if (
|
|
319
|
+
if (
|
|
320
|
+
parent instanceof Y.Map &&
|
|
321
|
+
(lastSeg.role === "field" || lastSeg.role === "entry")
|
|
322
|
+
) {
|
|
307
323
|
// Resolve schema for the target field for structured value detection
|
|
308
|
-
const targetSchema =
|
|
324
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
309
325
|
const yjsValue = maybeCreateSharedType(change.value, targetSchema)
|
|
310
|
-
//
|
|
326
|
+
// Identity-keying applies only at product-field boundaries; entry
|
|
327
|
+
// segments use the runtime key as-is.
|
|
311
328
|
let mapKey = resolved as string
|
|
312
|
-
if (binding) {
|
|
329
|
+
if (binding && lastSeg.role === "field") {
|
|
313
330
|
const absPath = path.segments
|
|
314
|
-
.filter(s => s.role === "
|
|
331
|
+
.filter(s => s.role === "field")
|
|
315
332
|
.map(s => s.resolve() as string)
|
|
316
333
|
.join(".")
|
|
317
334
|
const identity = binding.forward.get(absPath) as string | undefined
|
|
@@ -319,7 +336,7 @@ function applyReplaceChange(
|
|
|
319
336
|
}
|
|
320
337
|
parent.set(mapKey, yjsValue)
|
|
321
338
|
} else if (parent instanceof Y.Array && lastSeg.role === "index") {
|
|
322
|
-
const targetSchema =
|
|
339
|
+
const targetSchema = pathSchema(rootSchema, path)
|
|
323
340
|
const yjsValue = maybeCreateSharedType(change.value, targetSchema)
|
|
324
341
|
parent.delete(resolved as number, 1)
|
|
325
342
|
parent.insert(resolved as number, [yjsValue])
|
|
@@ -509,7 +526,7 @@ export function eventsToOps(
|
|
|
509
526
|
const ops: Op[] = []
|
|
510
527
|
|
|
511
528
|
for (const event of events) {
|
|
512
|
-
const kynetaPath = yjsPathToKynetaPath(event.path, binding)
|
|
529
|
+
const kynetaPath = yjsPathToKynetaPath(event.path, schema, binding)
|
|
513
530
|
const change = eventToChange(event, schema, kynetaPath, binding)
|
|
514
531
|
if (change) {
|
|
515
532
|
ops.push({ path: kynetaPath, change })
|
|
@@ -524,31 +541,55 @@ export function eventsToOps(
|
|
|
524
541
|
// ---------------------------------------------------------------------------
|
|
525
542
|
|
|
526
543
|
/**
|
|
527
|
-
* Convert a Yjs event path
|
|
544
|
+
* Convert a Yjs event path to a kyneta `RawPath`, walking the schema
|
|
545
|
+
* alongside so each segment is classified as field / entry / index by
|
|
546
|
+
* the current schema kind.
|
|
528
547
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
548
|
+
* Why schema-aware and not "did the inverse lookup hit?": the binding's
|
|
549
|
+
* inverse map only covers declared product-field positions reachable
|
|
550
|
+
* without crossing a runtime-keyed container. A declared struct field
|
|
551
|
+
* nested under a `record(...)` value type is reachable via Yjs but
|
|
552
|
+
* absent from `binding.inverse` — without the schema walk it would be
|
|
553
|
+
* misclassified as an entry and then rejected by `advanceSchema`.
|
|
531
554
|
*/
|
|
532
555
|
function yjsPathToKynetaPath(
|
|
533
556
|
yjsPath: (string | number)[],
|
|
557
|
+
rootSchema: SchemaNode,
|
|
534
558
|
binding?: SchemaBinding,
|
|
535
559
|
): RawPath {
|
|
536
560
|
let path = RawPath.empty
|
|
561
|
+
let schema: SchemaNode | undefined = rootSchema
|
|
537
562
|
for (const segment of yjsPath) {
|
|
538
563
|
if (typeof segment === "string") {
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
564
|
+
// Inverse-lookup recovers the original declared field name when
|
|
565
|
+
// the segment IS an identity hash; otherwise we keep the string.
|
|
566
|
+
let leaf = segment
|
|
542
567
|
const absPath = binding?.inverse.get(segment as any)
|
|
543
568
|
if (absPath) {
|
|
544
569
|
const lastDot = absPath.lastIndexOf(".")
|
|
545
|
-
|
|
570
|
+
leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
|
|
571
|
+
}
|
|
572
|
+
const kind = schema?.[KIND]
|
|
573
|
+
if (kind === "product") {
|
|
546
574
|
path = path.field(leaf)
|
|
575
|
+
schema = (schema as ProductSchema | undefined)?.fields[leaf]
|
|
576
|
+
} else if (kind === "map" || kind === "set" || kind === "tree") {
|
|
577
|
+
path = path.entry(leaf)
|
|
578
|
+
schema = (schema as any)?.item
|
|
547
579
|
} else {
|
|
548
|
-
|
|
580
|
+
// Unknown / sum / unrecognized — fall back to entry. Subsequent
|
|
581
|
+
// segments are likely walking plain JSON inside a sum variant.
|
|
582
|
+
path = path.entry(leaf)
|
|
583
|
+
schema = undefined
|
|
549
584
|
}
|
|
550
585
|
} else if (typeof segment === "number") {
|
|
551
586
|
path = path.item(segment)
|
|
587
|
+
const kind = schema?.[KIND]
|
|
588
|
+
if (kind === "sequence" || kind === "movable") {
|
|
589
|
+
schema = (schema as any).item
|
|
590
|
+
} else {
|
|
591
|
+
schema = undefined
|
|
592
|
+
}
|
|
552
593
|
}
|
|
553
594
|
}
|
|
554
595
|
return path
|
|
@@ -576,7 +617,7 @@ function eventToChange(
|
|
|
576
617
|
): ChangeBase | null {
|
|
577
618
|
if (event.target instanceof Y.Text) {
|
|
578
619
|
// Both text and richtext use Y.Text — resolve the schema to dispatch.
|
|
579
|
-
const schemaAtPath =
|
|
620
|
+
const schemaAtPath = pathSchema(rootSchema, kynetaPath)
|
|
580
621
|
if (schemaAtPath[KIND] === "richtext") {
|
|
581
622
|
return richTextEventToChange(event)
|
|
582
623
|
}
|
|
@@ -739,20 +780,6 @@ function extractEventValue(value: unknown): unknown {
|
|
|
739
780
|
// Schema helpers
|
|
740
781
|
// ---------------------------------------------------------------------------
|
|
741
782
|
|
|
742
|
-
/**
|
|
743
|
-
* Resolve the schema at a given path by walking through advanceSchema.
|
|
744
|
-
*/
|
|
745
|
-
function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
|
|
746
|
-
let schema = rootSchema
|
|
747
|
-
for (const seg of path.segments) {
|
|
748
|
-
schema = advanceSchema(schema, seg)
|
|
749
|
-
// Sum variants are always PlainSchema — cannot advance further.
|
|
750
|
-
// Return early; remaining segments address plain JSON values.
|
|
751
|
-
if (schema[KIND] === "sum") return schema
|
|
752
|
-
}
|
|
753
|
-
return schema
|
|
754
|
-
}
|
|
755
|
-
|
|
756
783
|
/**
|
|
757
784
|
* Get the item schema from a sequence schema, if available.
|
|
758
785
|
*/
|
package/src/index.ts
CHANGED
|
@@ -50,8 +50,7 @@ export type { YjsNativeMap } from "./native-map.js"
|
|
|
50
50
|
export { ensureContainers } from "./populate.js"
|
|
51
51
|
// Position conformance
|
|
52
52
|
export { fromYjsAssoc, toYjsAssoc, YjsPosition } from "./position.js"
|
|
53
|
-
|
|
54
|
-
export { yjsReader } from "./reader.js"
|
|
53
|
+
|
|
55
54
|
// Substrate
|
|
56
55
|
export {
|
|
57
56
|
createYjsSubstrate,
|
|
@@ -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":
|