@kyneta/yjs-schema 1.6.1 → 1.8.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/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, and that
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
- // This is NOT seed data it's structural completeness, matching what
8
- // PlainSubstrate does when it initializes its store with Zero.structural.
9
- // The Yjs store reader expects to find values at every schema path;
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, Zero } from "@kyneta/schema"
20
+ import { isJsonBoundary, 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
- * When `conditional` is true, fields that already exist in the root map
39
- * are skipped. This is the correct mode after hydration — containers
40
- * present from stored state must not be overwritten (each `rootMap.set()`
41
- * is a CRDT write that advances the version vector and may conflict
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"` → Zero.structural default
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,18 @@ 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
+
126
+ // JSON-boundary fields (struct.json/list.json/record.json) store the
127
+ // subtree as a plain JSON value in the root Y.Map entry. We leave the
128
+ // entry absent at structural-init time; the first write materialises
129
+ // it with `rootMap.set(key, plainValue)`.
130
+ if (isJsonBoundary(fieldSchema)) return
131
+
131
132
  switch (fieldSchema[KIND]) {
132
133
  case "text":
133
134
  case "richtext":
@@ -147,16 +148,10 @@ function ensureRootField(
147
148
  return
148
149
 
149
150
  case "scalar":
150
- case "sum": {
151
- // Plain values don't need shared type containers, but they DO
152
- // need structural zero defaults so the store reader returns
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
- }
151
+ case "sum":
152
+ // Value concerns are handled by the materializer's zero fallback.
153
+ // No CRDT writes needed for non-container types.
158
154
  return
159
- }
160
155
 
161
156
  case "counter":
162
157
  case "set":
@@ -180,7 +175,7 @@ function ensureRootField(
180
175
  *
181
176
  * Only creates containers for fields that require Yjs shared types
182
177
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
183
- * Scalar and sum fields are set to their structural zero defaults.
178
+ * Scalar and sum fields are skipped (materializer zero fallback).
184
179
  *
185
180
  * **Identity-keying:** When a `binding` is provided, computes the
186
181
  * absolute schema path for each nested field (`prefix.fieldName`) and
@@ -207,6 +202,12 @@ function ensureMapContainers(
207
202
  const identity = binding?.forward.get(absPath) as string | undefined
208
203
  const mapKey = identity ?? key
209
204
 
205
+ // JSON-boundary nested field: leave the entry absent, the first
206
+ // write will set the plain JSON value at this key.
207
+ if (isJsonBoundary(fieldSchema)) {
208
+ continue
209
+ }
210
+
210
211
  switch (fieldSchema[KIND]) {
211
212
  case "text":
212
213
  case "richtext":
@@ -226,13 +227,10 @@ function ensureMapContainers(
226
227
  break
227
228
 
228
229
  case "scalar":
229
- case "sum": {
230
- const zero = Zero.structural(fieldSchema)
231
- if (zero !== undefined) {
232
- map.set(mapKey, zero)
233
- }
230
+ case "sum":
231
+ // Value concerns are handled by the materializer's zero fallback.
232
+ // No CRDT writes needed for non-container types.
234
233
  break
235
- }
236
234
 
237
235
  case "counter":
238
236
  case "set":