@kyneta/yjs-schema 1.0.0 → 1.2.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/index.ts CHANGED
@@ -4,80 +4,61 @@
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
- // Text annotation convenience — so users don't need LoroSchema just for text()
37
- import type { AnnotatedSchema } from "@kyneta/schema"
38
- import { Schema } from "@kyneta/schema"
39
-
40
- /**
41
- * Collaborative text (CRDT). Produces `annotated("text")`.
42
- *
43
- * The annotation implies scalar string semantics for reads,
44
- * but the Yjs substrate provides collaborative editing (insert, delete)
45
- * via Y.Text.
46
- *
47
- * This is a convenience re-export so that `@kyneta/yjs-schema` users
48
- * don't need to import `LoroSchema` just for `text()`.
49
- */
50
- export function text(): AnnotatedSchema<"text", undefined> {
51
- return Schema.annotated("text")
52
- }
53
-
54
37
  // Types (re-exported for convenience)
55
- export type { Changeset, Op, Ref, SubstratePayload } from "@kyneta/schema"
38
+ export type { Changeset } from "@kyneta/changefeed"
39
+ export type { Op, Ref, SubstratePayload } from "@kyneta/schema"
56
40
 
57
41
  // ---------------------------------------------------------------------------
58
42
  // Low-level primitives — for power users and custom substrate compositions
59
43
  // ---------------------------------------------------------------------------
60
44
 
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
-
45
+ // Namespace — the yjs substrate namespace (replaces standalone escape hatch;
46
+ // the old `yjs(ref)` call is now `yjs.unwrap(ref)`)
47
+ export { yjs } from "./bind-yjs.js"
48
+ export type { YjsCaps } from "./bind-yjs.js"
70
49
  // Change mapping
71
50
  export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
72
-
73
51
  // Container creation
74
52
  export { ensureContainers } from "./populate.js"
75
-
53
+ // Reader
54
+ export { yjsReader } from "./reader.js"
76
55
  // Substrate
77
- export { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
78
-
79
- // Bind — convenience wrapper for Yjs CRDT substrate
80
- export { bindYjs } from "./bind-yjs.js"
81
-
82
- // Escape hatch — access the underlying Y.Doc from a ref
83
- export { yjs } from "./yjs-escape.js"
56
+ export {
57
+ createYjsSubstrate,
58
+ yjsReplicaFactory,
59
+ yjsSubstrateFactory,
60
+ } from "./substrate.js"
61
+ // Version
62
+ export { YjsVersion } from "./version.js"
63
+ // Container resolution
64
+ 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 { KIND, STRUCTURAL_YJS_CLIENT_ID, Zero } from "@kyneta/schema"
19
19
  import * as Y from "yjs"
20
20
 
21
21
  // ---------------------------------------------------------------------------
@@ -26,37 +26,58 @@ import * as Y from "yjs"
26
26
  * Ensure that a Y.Doc's root map contains the correct Yjs shared types
27
27
  * matching the schema structure.
28
28
  *
29
- * Obtains the root map via `doc.getMap("root")`, unwraps the root product
30
- * schema, and creates empty containers for each field within a single
31
- * `doc.transact()` call for atomicity.
29
+ * Obtains the root map via `doc.getMap("root")`, reads the root product
30
+ * schema's fields, and creates empty containers for each field within a
31
+ * single `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
- * @param schema - The root document schema (typically annotated("doc", product))
48
+ * @param schema - The root document schema (a ProductSchema)
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
- let rootProduct = schema
44
- while (
45
- rootProduct._kind === "annotated" &&
46
- rootProduct.schema !== undefined
47
- ) {
48
- rootProduct = rootProduct.schema
49
- }
50
-
51
- if (rootProduct._kind !== "product") {
59
+ if (schema[KIND] !== "product") {
52
60
  return
53
61
  }
54
62
 
55
- doc.transact(() => {
56
- for (const [key, fieldSchema] of Object.entries(rootProduct.fields)) {
57
- ensureRootField(rootMap, key, fieldSchema as SchemaNode)
58
- }
59
- })
63
+ // Switch to structural identity for deterministic container creation.
64
+ // All peers produce byte-identical structural ops at clientID 0.
65
+ const savedClientID = doc.clientID
66
+ doc.clientID = STRUCTURAL_YJS_CLIENT_ID
67
+
68
+ try {
69
+ doc.transact(() => {
70
+ for (const [key, fieldSchema] of Object.entries(schema.fields).sort(
71
+ ([a], [b]) => a.localeCompare(b),
72
+ )) {
73
+ if (conditional && rootMap.has(key)) continue
74
+ ensureRootField(rootMap, key, fieldSchema as SchemaNode)
75
+ }
76
+ })
77
+ } finally {
78
+ // Restore the caller's identity for application writes.
79
+ doc.clientID = savedClientID
80
+ }
60
81
  }
61
82
 
62
83
  // ---------------------------------------------------------------------------
@@ -66,62 +87,36 @@ export function ensureContainers(doc: Y.Doc, schema: SchemaNode): void {
66
87
  /**
67
88
  * Ensure a root-level Yjs shared type exists for a schema field.
68
89
  *
69
- * Dispatches based on the schema annotation tag and structural kind:
70
- * - `annotated("text")` → empty Y.Text
71
- * - `annotated("counter")` → throws (unsupported in Yjs)
72
- * - `annotated("movable")` → throws (unsupported in Yjs)
73
- * - `annotated("tree")` → throws (unsupported in Yjs)
74
- * - `product` → empty Y.Map (recursive for nested products)
75
- * - `sequence` → empty Y.Array
76
- * - `map` → empty Y.Map
77
- * - `scalar`/`sum` → no-op (plain values don't need containers)
90
+ * Dispatches on `[KIND]`:
91
+ * - `"text"` → empty Y.Text
92
+ * - `"product"` → empty Y.Map (recursive for nested products)
93
+ * - `"sequence"` → empty Y.Array
94
+ * - `"map"` → empty Y.Map
95
+ * - `"scalar"` / `"sum"` Zero.structural default
96
+ * - `"counter"` / `"set"` / `"tree"` / `"movable"` throw (not supported by Yjs)
78
97
  */
79
98
  function ensureRootField(
80
99
  rootMap: Y.Map<unknown>,
81
100
  key: string,
82
101
  fieldSchema: SchemaNode,
83
102
  ): void {
84
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
85
-
86
- switch (tag) {
103
+ switch (fieldSchema[KIND]) {
87
104
  case "text":
88
105
  rootMap.set(key, new Y.Text())
89
106
  return
90
107
 
91
- case "counter":
92
- throw new Error(
93
- `Yjs substrate does not support counter annotations. ` +
94
- `Use Schema.number() with ReplaceChange instead. ` +
95
- `Encountered counter annotation at root field "${key}".`,
96
- )
97
-
98
- case "movable":
99
- throw new Error(
100
- `Yjs substrate does not support movable list annotations. ` +
101
- `Yjs has no native movable list type. ` +
102
- `Encountered movable annotation at root field "${key}".`,
103
- )
104
-
105
- case "tree":
106
- throw new Error(
107
- `Yjs substrate does not support tree annotations. ` +
108
- `Yjs has no native tree type. ` +
109
- `Encountered tree annotation at root field "${key}".`,
110
- )
111
- }
112
-
113
- const structural = unwrapAnnotations(fieldSchema)
114
-
115
- switch (structural._kind) {
116
108
  case "product":
117
- rootMap.set(key, ensureMapContainers(structural))
109
+ rootMap.set(key, ensureMapContainers(fieldSchema))
118
110
  return
111
+
119
112
  case "sequence":
120
113
  rootMap.set(key, new Y.Array())
121
114
  return
115
+
122
116
  case "map":
123
117
  rootMap.set(key, new Y.Map())
124
118
  return
119
+
125
120
  case "scalar":
126
121
  case "sum": {
127
122
  // Plain values don't need shared type containers, but they DO
@@ -133,6 +128,16 @@ function ensureRootField(
133
128
  }
134
129
  return
135
130
  }
131
+
132
+ case "counter":
133
+ case "set":
134
+ case "tree":
135
+ case "movable":
136
+ throw new Error(
137
+ `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
138
+ `Supported kinds: text, product, sequence, map, scalar, sum. ` +
139
+ `Encountered unsupported kind at root field "${key}".`,
140
+ )
136
141
  }
137
142
  }
138
143
 
@@ -146,38 +151,33 @@ function ensureRootField(
146
151
  *
147
152
  * Only creates containers for fields that require Yjs shared types
148
153
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
149
- * Scalar and sum fields are left empty they'll be written as plain
150
- * values via change() when needed.
154
+ * Scalar and sum fields are set to their structural zero defaults.
151
155
  */
152
156
  function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
153
157
  const map = new Y.Map()
154
- const structural = unwrapAnnotations(schema)
155
158
 
156
- if (structural._kind !== "product") return map
159
+ if (schema[KIND] !== "product") return map
157
160
 
158
161
  for (const [key, fieldSchema] of Object.entries(
159
- structural.fields as Record<string, SchemaNode>,
160
- )) {
161
- const tag =
162
- fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
163
-
164
- if (tag === "text") {
165
- map.set(key, new Y.Text())
166
- continue
167
- }
168
-
169
- const fs = unwrapAnnotations(fieldSchema)
162
+ schema.fields as Record<string, SchemaNode>,
163
+ ).sort(([a], [b]) => a.localeCompare(b))) {
164
+ switch (fieldSchema[KIND]) {
165
+ case "text":
166
+ map.set(key, new Y.Text())
167
+ break
170
168
 
171
- switch (fs._kind) {
172
169
  case "product":
173
170
  map.set(key, ensureMapContainers(fieldSchema))
174
171
  break
172
+
175
173
  case "sequence":
176
174
  map.set(key, new Y.Array())
177
175
  break
176
+
178
177
  case "map":
179
178
  map.set(key, new Y.Map())
180
179
  break
180
+
181
181
  case "scalar":
182
182
  case "sum": {
183
183
  const zero = Zero.structural(fieldSchema)
@@ -186,23 +186,18 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
186
186
  }
187
187
  break
188
188
  }
189
+
190
+ case "counter":
191
+ case "set":
192
+ case "tree":
193
+ case "movable":
194
+ throw new Error(
195
+ `Yjs substrate does not support [KIND]="${fieldSchema[KIND]}". ` +
196
+ `Supported kinds: text, product, sequence, map, scalar, sum. ` +
197
+ `Encountered unsupported kind at nested field "${key}".`,
198
+ )
189
199
  }
190
200
  }
191
201
 
192
202
  return map
193
- }
194
-
195
- // ---------------------------------------------------------------------------
196
- // Helpers
197
- // ---------------------------------------------------------------------------
198
-
199
- /**
200
- * Unwrap annotation wrappers to reach the structural schema node.
201
- */
202
- function unwrapAnnotations(schema: SchemaNode): SchemaNode {
203
- let s = schema
204
- while (s._kind === "annotated" && s.schema !== undefined) {
205
- s = s.schema
206
- }
207
- return s
208
203
  }
@@ -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
+ }