@kyneta/yjs-schema 1.0.0 → 1.1.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/bind-yjs.ts CHANGED
@@ -19,18 +19,23 @@
19
19
  //
20
20
  // const doc = exchange.get("my-doc", TodoDoc)
21
21
 
22
- import { bind } from "@kyneta/schema"
23
- import type { BoundSchema } from "@kyneta/schema"
24
- import type { Schema as SchemaNode } from "@kyneta/schema"
25
22
  import type {
23
+ BoundSchema,
24
+ Replica,
25
+ Schema as SchemaNode,
26
26
  Substrate,
27
27
  SubstrateFactory,
28
28
  SubstratePayload,
29
29
  } from "@kyneta/schema"
30
+ import { BACKING_DOC, bind, STRUCTURAL_YJS_CLIENT_ID } from "@kyneta/schema"
30
31
  import * as Y from "yjs"
31
- import { createYjsSubstrate } from "./substrate.js"
32
- import { YjsVersion } from "./version.js"
33
32
  import { ensureContainers } from "./populate.js"
33
+ import {
34
+ createYjsReplica,
35
+ createYjsSubstrate,
36
+ yjsReplicaFactory,
37
+ } from "./substrate.js"
38
+ import { YjsVersion } from "./version.js"
34
39
 
35
40
  // ---------------------------------------------------------------------------
36
41
  // Peer ID hashing — deterministic string → numeric Yjs clientID
@@ -55,7 +60,9 @@ function hashPeerId(peerId: string): number {
55
60
  hash = Math.imul(hash, 0x01000193)
56
61
  }
57
62
  // Ensure unsigned 32-bit integer
58
- return hash >>> 0
63
+ const result = hash >>> 0
64
+ // Reserve 0 for structural ops — real peers never collide
65
+ return result === STRUCTURAL_YJS_CLIENT_ID ? 1 : result
59
66
  }
60
67
 
61
68
  // ---------------------------------------------------------------------------
@@ -67,36 +74,50 @@ function hashPeerId(peerId: string): number {
67
74
  * on every new Y.Doc with a deterministic uint32 clientID derived
68
75
  * from the exchange's string peerId.
69
76
  */
70
- function createYjsFactory(
71
- peerId: string,
72
- ): SubstrateFactory<YjsVersion> {
77
+ function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
73
78
  const numericClientId = hashPeerId(peerId)
74
79
 
75
80
  return {
81
+ replica: yjsReplicaFactory,
82
+
83
+ createReplica(): Replica<YjsVersion> {
84
+ // Default random clientID — safe for hydration (no local writes).
85
+ // Identity is set at upgrade() time, after hydration.
86
+ return createYjsReplica(new Y.Doc())
87
+ },
88
+
89
+ upgrade(
90
+ replica: Replica<YjsVersion>,
91
+ schema: SchemaNode,
92
+ ): Substrate<YjsVersion> {
93
+ const doc = (replica as any)[BACKING_DOC] as Y.Doc
94
+ // Set stable identity AFTER hydration — avoids Yjs clientID
95
+ // conflict detection that would reassign to a random value.
96
+ doc.clientID = numericClientId
97
+ // Conditional ensureContainers: skip fields that already exist
98
+ // from hydrated state (each set() is a CRDT write).
99
+ ensureContainers(doc, schema, true)
100
+ return createYjsSubstrate(doc, schema)
101
+ },
102
+
76
103
  create(schema: SchemaNode): Substrate<YjsVersion> {
104
+ // Fresh doc — set identity immediately, unconditional containers.
77
105
  const doc = new Y.Doc()
78
106
  doc.clientID = numericClientId
79
-
80
107
  ensureContainers(doc, schema)
81
108
  return createYjsSubstrate(doc, schema)
82
109
  },
83
110
 
84
- fromSnapshot(
111
+ fromEntirety(
85
112
  payload: SubstratePayload,
86
113
  schema: SchemaNode,
87
114
  ): Substrate<YjsVersion> {
88
- if (
89
- payload.encoding !== "binary" ||
90
- !(payload.data instanceof Uint8Array)
91
- ) {
92
- throw new Error(
93
- "YjsSubstrateFactory.fromSnapshot only supports binary-encoded payloads",
94
- )
95
- }
96
- const doc = new Y.Doc()
97
- doc.clientID = numericClientId
98
- Y.applyUpdate(doc, payload.data)
99
- return createYjsSubstrate(doc, schema)
115
+ // Two-phase path: createReplica → merge → upgrade
116
+ // Identity is set at upgrade() time, after hydration —
117
+ // avoids Yjs clientID conflict detection.
118
+ const replica = this.createReplica()
119
+ replica.merge(payload)
120
+ return this.upgrade(replica, schema)
100
121
  },
101
122
 
102
123
  parseVersion(serialized: string): YjsVersion {
@@ -141,7 +162,7 @@ function createYjsFactory(
141
162
  export function bindYjs<S extends SchemaNode>(schema: S): BoundSchema<S> {
142
163
  return bind({
143
164
  schema,
144
- factory: (ctx) => createYjsFactory(ctx.peerId),
165
+ factory: ctx => createYjsFactory(ctx.peerId),
145
166
  strategy: "causal",
146
167
  })
147
- }
168
+ }
@@ -16,7 +16,6 @@
16
16
  // This produces a single observeDeep event with the complete struct,
17
17
  // rather than a cascade of child MapChange events.
18
18
 
19
- import { advanceSchema, expandMapOpsToLeaves } from "@kyneta/schema"
20
19
  import type {
21
20
  ChangeBase,
22
21
  IncrementChange,
@@ -30,7 +29,7 @@ import type {
30
29
  TextChange,
31
30
  TextInstruction,
32
31
  } from "@kyneta/schema"
33
- import { RawPath } from "@kyneta/schema"
32
+ import { advanceSchema, expandMapOpsToLeaves, RawPath } from "@kyneta/schema"
34
33
  import * as Y from "yjs"
35
34
  import { resolveYjsType } from "./yjs-resolve.js"
36
35
 
@@ -146,7 +145,7 @@ function applySequenceChange(
146
145
  // cursor stays — deleted items shift remaining items down
147
146
  } else if ("insert" in instruction) {
148
147
  const items = instruction.insert as readonly unknown[]
149
- const yjsItems = items.map((item) =>
148
+ const yjsItems = items.map(item =>
150
149
  maybeCreateSharedType(item, itemSchema),
151
150
  )
152
151
  resolved.insert(cursor, yjsItems)
@@ -265,9 +264,7 @@ function maybeCreateSharedType(
265
264
 
266
265
  // Annotated counter/movable/tree → should not reach here (thrown earlier)
267
266
  if (tag === "counter" || tag === "movable" || tag === "tree") {
268
- throw new Error(
269
- `Yjs substrate does not support "${tag}" annotations.`,
270
- )
267
+ throw new Error(`Yjs substrate does not support "${tag}" annotations.`)
271
268
  }
272
269
 
273
270
  switch (structural._kind) {
@@ -280,17 +277,14 @@ function maybeCreateSharedType(
280
277
  ) {
281
278
  return value
282
279
  }
283
- return createStructuredMap(
284
- value as Record<string, unknown>,
285
- structural,
286
- )
280
+ return createStructuredMap(value as Record<string, unknown>, structural)
287
281
  }
288
282
 
289
283
  case "sequence": {
290
284
  if (!Array.isArray(value)) return value
291
285
  const arr = new Y.Array()
292
286
  const itemSchema = structural.item
293
- const items = (value as unknown[]).map((item) =>
287
+ const items = (value as unknown[]).map(item =>
294
288
  maybeCreateSharedType(item, itemSchema),
295
289
  )
296
290
  arr.insert(0, items)
@@ -308,9 +302,7 @@ function maybeCreateSharedType(
308
302
  }
309
303
  const map = new Y.Map()
310
304
  const valueSchema = structural.item
311
- for (const [k, v] of Object.entries(
312
- value as Record<string, unknown>,
313
- )) {
305
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
314
306
  map.set(k, maybeCreateSharedType(v, valueSchema))
315
307
  }
316
308
  return map
@@ -348,9 +340,7 @@ function createStructuredMap(
348
340
  for (const [key, val] of Object.entries(obj)) {
349
341
  if (val === undefined) continue
350
342
  const fieldSchema = structural.fields[key]
351
- const yjsVal = fieldSchema
352
- ? maybeCreateSharedType(val, fieldSchema)
353
- : val
343
+ const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val
354
344
  map.set(key, yjsVal)
355
345
  }
356
346
 
@@ -362,8 +352,7 @@ function createStructuredMap(
362
352
  structural.fields as Record<string, SchemaNode>,
363
353
  )) {
364
354
  if (key in obj) continue // already processed above
365
- const tag =
366
- fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
355
+ const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
367
356
  if (tag === "text") {
368
357
  map.set(key, new Y.Text())
369
358
  }
@@ -512,18 +501,16 @@ function mapEventToChange(event: Y.YEvent<any>): MapChange | null {
512
501
 
513
502
  const target = event.target as Y.Map<any>
514
503
 
515
- event.changes.keys.forEach(
516
- (change: { action: string }, key: string) => {
517
- if (change.action === "add" || change.action === "update") {
518
- const value = target.get(key)
519
- set[key] = extractEventValue(value)
520
- hasSet = true
521
- } else if (change.action === "delete") {
522
- deleteKeys.push(key)
523
- hasDelete = true
524
- }
525
- },
526
- )
504
+ event.changes.keys.forEach((change: { action: string }, key: string) => {
505
+ if (change.action === "add" || change.action === "update") {
506
+ const value = target.get(key)
507
+ set[key] = extractEventValue(value)
508
+ hasSet = true
509
+ } else if (change.action === "delete") {
510
+ deleteKeys.push(key)
511
+ hasDelete = true
512
+ }
513
+ })
527
514
 
528
515
  if (!hasSet && !hasDelete) return null
529
516
 
@@ -606,7 +593,5 @@ function getFieldSchema(
606
593
  // ---------------------------------------------------------------------------
607
594
 
608
595
  function pathToString(path: Path): string {
609
- return path.segments
610
- .map((seg) => String(seg.resolve()))
611
- .join(".")
612
- }
596
+ return path.segments.map(seg => String(seg.resolve())).join(".")
597
+ }
package/src/create.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // create — batteries-included document construction backed by YjsSubstrate.
2
2
  //
3
- // Provides `createYjsDoc` and `createYjsDocFromSnapshot` functions that
3
+ // Provides `createYjsDoc` and `createYjsDocFromEntirety` functions that
4
4
  // hide the interpret pipeline and layer composition behind a single call.
5
5
  //
6
6
  // Internally tracks substrates via a module-scoped WeakMap so that sync
7
- // primitives (`version`, `exportSnapshot`, `importDelta` in sync.ts)
7
+ // primitives (`version`, `exportEntirety`, `merge` in sync.ts)
8
8
  // can retrieve the substrate from just a doc ref.
9
9
  //
10
10
  // `getSubstrate` is exported for use by `sync.ts` but is NOT re-exported
@@ -14,14 +14,22 @@
14
14
  // createYjsDoc(schema, yjsDoc) — "bring your own doc" (wrap existing)
15
15
  // createYjsDoc(schema) — create a fresh empty Y.Doc
16
16
 
17
- import { interpret, registerSubstrate } from "@kyneta/schema"
18
- import { changefeed, readable, writable } from "@kyneta/schema"
19
- import type { Ref } from "@kyneta/schema"
20
- import type { Schema as SchemaType } from "@kyneta/schema"
21
- import type { Substrate, SubstratePayload } from "@kyneta/schema"
22
- import * as Y from "yjs"
23
- import { YjsVersion } from "./version.js"
17
+ import type {
18
+ Ref,
19
+ Schema as SchemaType,
20
+ Substrate,
21
+ SubstratePayload,
22
+ } from "@kyneta/schema"
23
+ import {
24
+ changefeed,
25
+ interpret,
26
+ readable,
27
+ registerSubstrate,
28
+ writable,
29
+ } from "@kyneta/schema"
30
+ import type * as Y from "yjs"
24
31
  import { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
32
+ import type { YjsVersion } from "./version.js"
25
33
 
26
34
  // ---------------------------------------------------------------------------
27
35
  // Substrate tracking (module-scoped)
@@ -31,18 +39,18 @@ const substrates = new WeakMap<object, Substrate<YjsVersion>>()
31
39
 
32
40
  /**
33
41
  * Retrieve the substrate associated with a doc created by `createYjsDoc`
34
- * or `createYjsDocFromSnapshot`.
42
+ * or `createYjsDocFromEntirety`.
35
43
  *
36
44
  * Exported for `sync.ts` — NOT re-exported from the barrel.
37
45
  *
38
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
46
+ * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
39
47
  */
40
48
  export function getSubstrate(doc: object): Substrate<YjsVersion> {
41
49
  const s = substrates.get(doc)
42
50
  if (!s) {
43
51
  throw new Error(
44
- "version/exportSnapshot/importDelta called on an object without a YjsSubstrate. " +
45
- "Use a doc created by createYjsDoc() or createYjsDocFromSnapshot().",
52
+ "version/exportEntirety/merge called on an object without a YjsSubstrate. " +
53
+ "Use a doc created by createYjsDoc() or createYjsDocFromEntirety().",
46
54
  )
47
55
  }
48
56
  return s
@@ -58,7 +66,7 @@ function registerDoc(
58
66
  ): any {
59
67
  // The `as any` on the builder avoids TS2589 — interpret's fluent API
60
68
  // produces deeply recursive types when S is the abstract SchemaType.
61
- // The public createYjsDoc/createYjsDocFromSnapshot signatures provide
69
+ // The public createYjsDoc/createYjsDocFromEntirety signatures provide
62
70
  // the correct Ref<S> return type via interface call signature patterns.
63
71
  const doc: any = (interpret as any)(schema, substrate.context())
64
72
  .with(readable)
@@ -123,17 +131,14 @@ function isYDoc(value: unknown): value is Y.Doc {
123
131
  * CRDT collaboration support.
124
132
  *
125
133
  * The returned ref observes **all** mutations to the underlying Y.Doc,
126
- * regardless of source (local kyneta writes, importDelta, external
134
+ * regardless of source (local kyneta writes, merge, external
127
135
  * `Y.applyUpdate()`, external raw Yjs API mutations).
128
136
  *
129
137
  * @param schema - The schema describing the document structure.
130
138
  * @param doc - Optional `Y.Doc` instance to wrap. If omitted, a fresh
131
139
  * empty Y.Doc is created with containers matching the schema.
132
140
  */
133
- type CreateYjsDoc = <S extends SchemaType>(
134
- schema: S,
135
- doc?: Y.Doc,
136
- ) => Ref<S>
141
+ type CreateYjsDoc = <S extends SchemaType>(schema: S, doc?: Y.Doc) => Ref<S>
137
142
 
138
143
  export const createYjsDoc: CreateYjsDoc = (schema, doc) => {
139
144
  if (doc !== undefined && isYDoc(doc)) {
@@ -145,28 +150,28 @@ export const createYjsDoc: CreateYjsDoc = (schema, doc) => {
145
150
  }
146
151
 
147
152
  // ---------------------------------------------------------------------------
148
- // createYjsDocFromSnapshot
153
+ // createYjsDocFromEntirety
149
154
  // ---------------------------------------------------------------------------
150
155
 
151
- type CreateYjsDocFromSnapshot = <S extends SchemaType>(
156
+ type CreateYjsDocFromEntirety = <S extends SchemaType>(
152
157
  schema: S,
153
158
  payload: SubstratePayload,
154
159
  ) => Ref<S>
155
160
 
156
161
  /**
157
- * Reconstruct a live Yjs-backed document from a substrate snapshot payload.
162
+ * Reconstruct a live Yjs-backed document from a substrate entirety payload.
158
163
  *
159
- * The payload must have been produced by `exportSnapshot()` on a
164
+ * The payload must have been produced by `exportEntirety()` on a
160
165
  * compatible document. This is the entry point for SSR hydration
161
166
  * and reconnection past log compaction.
162
167
  *
163
168
  * ```ts
164
- * const payload = exportSnapshot(docA)
165
- * const docB = createYjsDocFromSnapshot(MySchema, payload)
169
+ * const payload = exportEntirety(docA)
170
+ * const docB = createYjsDocFromEntirety(MySchema, payload)
166
171
  * // docB has the same state as docA at the time of export
167
172
  * ```
168
173
  */
169
- export const createYjsDocFromSnapshot: CreateYjsDocFromSnapshot = (
174
+ export const createYjsDocFromEntirety: CreateYjsDocFromEntirety = (
170
175
  schema,
171
176
  payload,
172
- ) => registerDoc(schema, yjsSubstrateFactory.fromSnapshot(payload, schema))
177
+ ) => registerDoc(schema, yjsSubstrateFactory.fromEntirety(payload, schema))
package/src/index.ts CHANGED
@@ -4,35 +4,36 @@
4
4
  // with schema-aware typed reads, writes, versioning, and export/import.
5
5
  //
6
6
  // Batteries-included API (most users):
7
- // createYjsDoc, createYjsDocFromSnapshot, version, exportSnapshot,
8
- // exportSince, importDelta, change, subscribe, applyChanges
7
+ // createYjsDoc, createYjsDocFromEntirety, version, exportEntirety,
8
+ // exportSince, merge, change, subscribe, applyChanges
9
9
  //
10
10
  // Low-level primitives (power users):
11
- // createYjsSubstrate, yjsSubstrateFactory, yjsStoreReader,
11
+ // createYjsSubstrate, yjsSubstrateFactory, yjsReader,
12
12
  // resolveYjsType, stepIntoYjs, applyChangeToYjs, eventsToOps, YjsVersion
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Batteries-included API — one import, one createYjsDoc call, done
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
+ // Mutation & observation (re-exported from @kyneta/schema for convenience)
19
+ // Schema definition (re-exported for convenience)
20
+ export {
21
+ applyChanges,
22
+ change,
23
+ Schema,
24
+ subscribe,
25
+ subscribeNode,
26
+ } from "@kyneta/schema"
18
27
  // Construction
19
- export { createYjsDoc, createYjsDocFromSnapshot } from "./create.js"
20
-
28
+ export { createYjsDoc, createYjsDocFromEntirety } from "./create.js"
21
29
  // Sync primitives (Yjs-specific)
22
30
  export {
31
+ exportEntirety,
23
32
  exportSince,
24
- exportSnapshot,
25
- importDelta,
33
+ merge,
26
34
  version,
27
35
  } from "./sync.js"
28
36
 
29
- // Mutation & observation (re-exported from @kyneta/schema for convenience)
30
- export { applyChanges, change } from "@kyneta/schema"
31
- export { subscribe, subscribeNode } from "@kyneta/schema"
32
-
33
- // Schema definition (re-exported for convenience)
34
- export { Schema } from "@kyneta/schema"
35
-
36
37
  // Text annotation convenience — so users don't need LoroSchema just for text()
37
38
  import type { AnnotatedSchema } from "@kyneta/schema"
38
39
  import { Schema } from "@kyneta/schema"
@@ -58,26 +59,19 @@ export type { Changeset, Op, Ref, SubstratePayload } from "@kyneta/schema"
58
59
  // Low-level primitives — for power users and custom substrate compositions
59
60
  // ---------------------------------------------------------------------------
60
61
 
61
- // Version
62
- export { YjsVersion } from "./version.js"
63
-
64
- // Store reader
65
- export { yjsStoreReader } from "./store-reader.js"
66
-
67
- // Container resolution
68
- export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
69
-
62
+ // Bind — convenience wrapper for Yjs CRDT substrate
63
+ export { bindYjs } from "./bind-yjs.js"
70
64
  // Change mapping
71
65
  export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
72
-
73
66
  // Container creation
74
67
  export { ensureContainers } from "./populate.js"
75
-
68
+ // Reader
69
+ export { yjsReader } from "./reader.js"
76
70
  // Substrate
77
71
  export { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
78
-
79
- // Bind convenience wrapper for Yjs CRDT substrate
80
- export { bindYjs } from "./bind-yjs.js"
81
-
72
+ // Version
73
+ export { YjsVersion } from "./version.js"
82
74
  // Escape hatch — access the underlying Y.Doc from a ref
83
- export { yjs } from "./yjs-escape.js"
75
+ export { yjs } from "./yjs-escape.js"
76
+ // Container resolution
77
+ 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 { Zero } from "@kyneta/schema"
18
+ import { STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
19
19
  import * as Y from "yjs"
20
20
 
21
21
  // ---------------------------------------------------------------------------
@@ -30,14 +30,30 @@ import * as Y from "yjs"
30
30
  * schema, and creates empty containers for each field within a single
31
31
  * `doc.transact()` call for atomicity.
32
32
  *
33
- * No values are written the containers are empty after this call.
34
- * Initial content should be applied via `change()` after substrate
35
- * construction.
33
+ * When `conditional` is true, fields that already exist in the root map
34
+ * are skipped. This is the correct mode after hydration — containers
35
+ * present from stored state must not be overwritten (each `rootMap.set()`
36
+ * is a CRDT write that advances the version vector and may conflict
37
+ * with stored operations).
38
+ *
39
+ * When `conditional` is false (default), all fields are created
40
+ * unconditionally. This is the correct mode for fresh documents.
41
+ *
42
+ * **Structural identity:** This function temporarily sets `doc.clientID`
43
+ * to `STRUCTURAL_YJS_CLIENT_ID` (0) for the duration of container creation,
44
+ * then restores the caller's clientID. This produces byte-identical
45
+ * structural ops across all peers, enabling Yjs deduplication on merge.
36
46
  *
37
47
  * @param doc - The Y.Doc to prepare
38
48
  * @param schema - The root document schema (typically annotated("doc", product))
49
+ * @param conditional - If true, skip fields that already exist in the root map.
50
+ * Context: jj:smmulzkm (two-phase substrate construction)
39
51
  */
40
- export function ensureContainers(doc: Y.Doc, schema: SchemaNode): void {
52
+ export function ensureContainers(
53
+ doc: Y.Doc,
54
+ schema: SchemaNode,
55
+ conditional = false,
56
+ ): void {
41
57
  const rootMap = doc.getMap("root")
42
58
 
43
59
  let rootProduct = schema
@@ -52,11 +68,24 @@ export function ensureContainers(doc: Y.Doc, schema: SchemaNode): void {
52
68
  return
53
69
  }
54
70
 
55
- doc.transact(() => {
56
- for (const [key, fieldSchema] of Object.entries(rootProduct.fields)) {
57
- ensureRootField(rootMap, key, fieldSchema as SchemaNode)
58
- }
59
- })
71
+ // Switch to structural identity for deterministic container creation.
72
+ // All peers produce byte-identical structural ops at clientID 0.
73
+ const savedClientID = doc.clientID
74
+ doc.clientID = STRUCTURAL_YJS_CLIENT_ID
75
+
76
+ try {
77
+ doc.transact(() => {
78
+ for (const [key, fieldSchema] of Object.entries(rootProduct.fields).sort(
79
+ ([a], [b]) => a.localeCompare(b),
80
+ )) {
81
+ if (conditional && rootMap.has(key)) continue
82
+ ensureRootField(rootMap, key, fieldSchema as SchemaNode)
83
+ }
84
+ })
85
+ } finally {
86
+ // Restore the caller's identity for application writes.
87
+ doc.clientID = savedClientID
88
+ }
60
89
  }
61
90
 
62
91
  // ---------------------------------------------------------------------------
@@ -157,9 +186,8 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
157
186
 
158
187
  for (const [key, fieldSchema] of Object.entries(
159
188
  structural.fields as Record<string, SchemaNode>,
160
- )) {
161
- const tag =
162
- fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
189
+ ).sort(([a], [b]) => a.localeCompare(b))) {
190
+ const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
163
191
 
164
192
  if (tag === "text") {
165
193
  map.set(key, new Y.Text())
@@ -205,4 +233,4 @@ function unwrapAnnotations(schema: SchemaNode): SchemaNode {
205
233
  s = s.schema
206
234
  }
207
235
  return s
208
- }
236
+ }
@@ -1,6 +1,6 @@
1
- // store-reader — YjsStoreReader implementation.
1
+ // store-reader — YjsReader implementation.
2
2
  //
3
- // Implements StoreReader via schema-guided live navigation of the
3
+ // Implements Reader via schema-guided live navigation of the
4
4
  // Yjs shared type tree. Each read operation resolves the shared type
5
5
  // at the given path using resolveYjsType, then extracts the
6
6
  // appropriate value based on `instanceof` discrimination.
@@ -8,9 +8,7 @@
8
8
  // Y.Text → .toJSON() (string), Y.Map → .toJSON() (plain object),
9
9
  // Y.Array → .toJSON() (plain array), plain values → as-is.
10
10
 
11
- import type { StoreReader } from "@kyneta/schema"
12
- import type { Path } from "@kyneta/schema"
13
- import type { Schema as SchemaNode } from "@kyneta/schema"
11
+ import type { Path, Reader, Schema as SchemaNode } from "@kyneta/schema"
14
12
  import * as Y from "yjs"
15
13
  import { resolveYjsType } from "./yjs-resolve.js"
16
14
 
@@ -41,11 +39,11 @@ function extractValue(resolved: unknown): unknown {
41
39
  }
42
40
 
43
41
  // ---------------------------------------------------------------------------
44
- // yjsStoreReader
42
+ // yjsReader
45
43
  // ---------------------------------------------------------------------------
46
44
 
47
45
  /**
48
- * Creates a StoreReader that navigates the Yjs shared type tree live,
46
+ * Creates a Reader that navigates the Yjs shared type tree live,
49
47
  * using the schema as a type witness to determine navigation at each
50
48
  * path segment.
51
49
  *
@@ -58,10 +56,7 @@ function extractValue(resolved: unknown): unknown {
58
56
  * @param doc - The Y.Doc to read from.
59
57
  * @param schema - The root schema for the document.
60
58
  */
61
- export function yjsStoreReader(
62
- doc: Y.Doc,
63
- schema: SchemaNode,
64
- ): StoreReader {
59
+ export function yjsReader(doc: Y.Doc, schema: SchemaNode): Reader {
65
60
  const rootMap = doc.getMap("root")
66
61
 
67
62
  return {
@@ -120,4 +115,4 @@ export function yjsStoreReader(
120
115
  return false
121
116
  },
122
117
  }
123
- }
118
+ }