@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.
@@ -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 = resolveSchemaAtPath(rootSchema, path)
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 = resolveSchemaAtPath(rootSchema, path)
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 === "key")
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 (parent instanceof Y.Map && lastSeg.role === "key") {
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 = resolveSchemaAtPath(rootSchema, path)
324
+ const targetSchema = pathSchema(rootSchema, path)
309
325
  const yjsValue = maybeCreateSharedType(change.value, targetSchema)
310
- // Use identity hash for product-field boundaries.
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 === "key")
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 = resolveSchemaAtPath(rootSchema, path)
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 (array of string | number) to a kyneta 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
- * `event.path` from `observeDeep` is relative to the observed type.
530
- * Strings become key segments, numbers become index segments.
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
- // Reverse-map identity hash absolute schema path → leaf field name.
540
- // Yjs events emit identity-keyed strings at product-field positions;
541
- // we need to recover the original field name for kyneta schema paths.
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
- const leaf = lastDot >= 0 ? absPath.slice(lastDot + 1) : absPath
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
- path = path.field(segment)
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 = resolveSchemaAtPath(rootSchema, kynetaPath)
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
- // Reader
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, 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 { 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,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
- // 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
- }
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 set to their structural zero defaults.
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
- const zero = Zero.structural(fieldSchema)
231
- if (zero !== undefined) {
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":