@kyneta/yjs-schema 1.1.0 → 1.3.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.
@@ -29,7 +29,12 @@ import type {
29
29
  TextChange,
30
30
  TextInstruction,
31
31
  } from "@kyneta/schema"
32
- import { advanceSchema, expandMapOpsToLeaves, RawPath } from "@kyneta/schema"
32
+ import {
33
+ advanceSchema,
34
+ expandMapOpsToLeaves,
35
+ KIND,
36
+ RawPath,
37
+ } from "@kyneta/schema"
33
38
  import * as Y from "yjs"
34
39
  import { resolveYjsType } from "./yjs-resolve.js"
35
40
 
@@ -74,15 +79,15 @@ export function applyChangeToYjs(
74
79
 
75
80
  case "increment":
76
81
  throw new Error(
77
- "Yjs substrate does not support counter annotations. " +
78
- "Use Schema.number() with ReplaceChange instead. " +
82
+ `Yjs substrate does not support "${change.type}" changes. ` +
83
+ `Counter requires a CRDT backend that supports counters (e.g. Loro). ` +
79
84
  `Attempted IncrementChange with amount=${(change as IncrementChange).amount} at path [${pathToString(path)}].`,
80
85
  )
81
86
 
82
87
  case "tree":
83
88
  throw new Error(
84
- "Yjs substrate does not support tree annotations. " +
85
- "Yjs has no native tree type. " +
89
+ `Yjs substrate does not support "${change.type}" changes. ` +
90
+ `Tree requires a CRDT backend that supports trees (e.g. Loro). ` +
86
91
  `Attempted TreeChange at path [${pathToString(path)}].`,
87
92
  )
88
93
 
@@ -250,24 +255,16 @@ function maybeCreateSharedType(
250
255
  ): unknown {
251
256
  if (schema === undefined) return value
252
257
 
253
- const structural = unwrapAnnotations(schema)
254
- const tag = schema._kind === "annotated" ? schema.tag : undefined
255
-
256
- // Annotated text Y.Text
257
- if (tag === "text") {
258
- const text = new Y.Text()
259
- if (typeof value === "string" && value.length > 0) {
260
- text.insert(0, value)
258
+ switch (schema[KIND]) {
259
+ // First-class text Y.Text
260
+ case "text": {
261
+ const text = new Y.Text()
262
+ if (typeof value === "string" && value.length > 0) {
263
+ text.insert(0, value)
264
+ }
265
+ return text
261
266
  }
262
- return text
263
- }
264
267
 
265
- // Annotated counter/movable/tree → should not reach here (thrown earlier)
266
- if (tag === "counter" || tag === "movable" || tag === "tree") {
267
- throw new Error(`Yjs substrate does not support "${tag}" annotations.`)
268
- }
269
-
270
- switch (structural._kind) {
271
268
  case "product": {
272
269
  if (
273
270
  value === null ||
@@ -277,13 +274,13 @@ function maybeCreateSharedType(
277
274
  ) {
278
275
  return value
279
276
  }
280
- return createStructuredMap(value as Record<string, unknown>, structural)
277
+ return createStructuredMap(value as Record<string, unknown>, schema)
281
278
  }
282
279
 
283
280
  case "sequence": {
284
281
  if (!Array.isArray(value)) return value
285
282
  const arr = new Y.Array()
286
- const itemSchema = structural.item
283
+ const itemSchema = schema.item
287
284
  const items = (value as unknown[]).map(item =>
288
285
  maybeCreateSharedType(item, itemSchema),
289
286
  )
@@ -301,15 +298,26 @@ function maybeCreateSharedType(
301
298
  return value
302
299
  }
303
300
  const map = new Y.Map()
304
- const valueSchema = structural.item
301
+ const valueSchema = schema.item
305
302
  for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
306
303
  map.set(k, maybeCreateSharedType(v, valueSchema))
307
304
  }
308
305
  return map
309
306
  }
310
307
 
308
+ // Unsupported first-class CRDT types — should not reach here
309
+ // (rejected at bind time by caps check)
310
+ case "counter":
311
+ case "set":
312
+ case "tree":
313
+ case "movable":
314
+ throw new Error(
315
+ `Yjs substrate does not support [KIND]="${schema[KIND]}". ` +
316
+ `This should have been caught at bind() time.`,
317
+ )
318
+
311
319
  default:
312
- // Scalar, sum, or other — return as plain value
320
+ // Scalar, sum — return as plain value
313
321
  return value
314
322
  }
315
323
  }
@@ -326,9 +334,8 @@ function createStructuredMap(
326
334
  productSchema: SchemaNode,
327
335
  ): Y.Map<any> {
328
336
  const map = new Y.Map()
329
- const structural = unwrapAnnotations(productSchema)
330
337
 
331
- if (structural._kind !== "product") {
338
+ if (productSchema[KIND] !== "product") {
332
339
  // Fallback: set all values as plain
333
340
  for (const [key, val] of Object.entries(obj)) {
334
341
  map.set(key, val)
@@ -339,25 +346,22 @@ function createStructuredMap(
339
346
  // Process fields present in the value object
340
347
  for (const [key, val] of Object.entries(obj)) {
341
348
  if (val === undefined) continue
342
- const fieldSchema = structural.fields[key]
349
+ const fieldSchema = productSchema.fields[key]
343
350
  const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val
344
351
  map.set(key, yjsVal)
345
352
  }
346
353
 
347
- // Create shared types for annotated fields declared in the schema
354
+ // Create shared types for first-class CRDT fields declared in the schema
348
355
  // but missing from the value object. This ensures Yjs containers
349
356
  // exist for later mutation (e.g. .insert() on a text field inside
350
357
  // a struct inside a record/list).
351
358
  for (const [key, fieldSchema] of Object.entries(
352
- structural.fields as Record<string, SchemaNode>,
359
+ productSchema.fields as Record<string, SchemaNode>,
353
360
  )) {
354
361
  if (key in obj) continue // already processed above
355
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
356
- if (tag === "text") {
362
+ if (fieldSchema[KIND] === "text") {
357
363
  map.set(key, new Y.Text())
358
364
  }
359
- // Other annotated container types (counter, movable, tree) are
360
- // unsupported in Yjs and will throw if used elsewhere.
361
365
  }
362
366
 
363
367
  return map
@@ -380,7 +384,7 @@ function createStructuredMap(
380
384
  *
381
385
  * @param events - The events from the `observeDeep` callback
382
386
  */
383
- export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
387
+ export function eventsToOps(events: Y.YEvent<any>[], schema: SchemaNode): Op[] {
384
388
  const ops: Op[] = []
385
389
 
386
390
  for (const event of events) {
@@ -391,7 +395,7 @@ export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
391
395
  }
392
396
  }
393
397
 
394
- return expandMapOpsToLeaves(ops)
398
+ return expandMapOpsToLeaves(ops, schema)
395
399
  }
396
400
 
397
401
  // ---------------------------------------------------------------------------
@@ -541,17 +545,6 @@ function extractEventValue(value: unknown): unknown {
541
545
  // Schema helpers
542
546
  // ---------------------------------------------------------------------------
543
547
 
544
- /**
545
- * Unwrap annotation wrappers to reach the structural schema node.
546
- */
547
- function unwrapAnnotations(schema: SchemaNode): SchemaNode {
548
- let s = schema
549
- while (s._kind === "annotated" && s.schema !== undefined) {
550
- s = s.schema
551
- }
552
- return s
553
- }
554
-
555
548
  /**
556
549
  * Resolve the schema at a given path by walking through advanceSchema.
557
550
  */
@@ -567,8 +560,9 @@ function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
567
560
  * Get the item schema from a sequence schema, if available.
568
561
  */
569
562
  function getItemSchema(schema: SchemaNode): SchemaNode | undefined {
570
- const structural = unwrapAnnotations(schema)
571
- return structural._kind === "sequence" ? structural.item : undefined
563
+ if (schema[KIND] === "sequence") return schema.item
564
+ if (schema[KIND] === "movable") return schema.item
565
+ return undefined
572
566
  }
573
567
 
574
568
  /**
@@ -578,12 +572,14 @@ function getFieldSchema(
578
572
  schema: SchemaNode,
579
573
  key: string,
580
574
  ): SchemaNode | undefined {
581
- const structural = unwrapAnnotations(schema)
582
- if (structural._kind === "product") {
583
- return structural.fields[key]
575
+ if (schema[KIND] === "product") {
576
+ return schema.fields[key]
577
+ }
578
+ if (schema[KIND] === "map") {
579
+ return schema.item
584
580
  }
585
- if (structural._kind === "map") {
586
- return structural.item
581
+ if (schema[KIND] === "set") {
582
+ return schema.item
587
583
  }
588
584
  return undefined
589
585
  }
package/src/index.ts CHANGED
@@ -3,75 +3,60 @@
3
3
  // Provides a Substrate<YjsVersion> implementation that wraps a Y.Doc
4
4
  // with schema-aware typed reads, writes, versioning, and export/import.
5
5
  //
6
- // Batteries-included API (most users):
7
- // createYjsDoc, createYjsDocFromEntirety, version, exportEntirety,
8
- // exportSince, merge, change, subscribe, applyChanges
9
- //
10
- // Low-level primitives (power users):
11
- // createYjsSubstrate, yjsSubstrateFactory, yjsReader,
12
- // resolveYjsType, stepIntoYjs, applyChangeToYjs, eventsToOps, YjsVersion
6
+ // The single entry point is `createDoc(yjs.bind(schema))`. For the
7
+ // batteries-included API, import from this package. For the composable
8
+ // toolkit, import from `@kyneta/schema` directly.
13
9
 
14
10
  // ---------------------------------------------------------------------------
15
- // Batteries-included API one import, one createYjsDoc call, done
11
+ // Generic API (re-exported from @kyneta/schema for convenience)
16
12
  // ---------------------------------------------------------------------------
17
13
 
14
+ // Types (re-exported for convenience)
15
+ export type { Changeset } from "@kyneta/changefeed"
16
+ export type { DocRef, Op, Ref, SubstratePayload } from "@kyneta/schema"
17
+ // Construction
18
18
  // Mutation & observation (re-exported from @kyneta/schema for convenience)
19
19
  // Schema definition (re-exported for convenience)
20
+ // Native escape hatch
21
+ // Sync primitives (generic — work for any substrate)
20
22
  export {
21
23
  applyChanges,
22
24
  change,
23
- Schema,
24
- subscribe,
25
- subscribeNode,
26
- } from "@kyneta/schema"
27
- // Construction
28
- export { createYjsDoc, createYjsDocFromEntirety } from "./create.js"
29
- // Sync primitives (Yjs-specific)
30
- export {
25
+ createDoc,
26
+ createRef,
31
27
  exportEntirety,
32
28
  exportSince,
33
29
  merge,
30
+ NATIVE,
31
+ Schema,
32
+ subscribe,
33
+ subscribeNode,
34
+ unwrap,
34
35
  version,
35
- } from "./sync.js"
36
-
37
- // Text annotation convenience — so users don't need LoroSchema just for text()
38
- import type { AnnotatedSchema } from "@kyneta/schema"
39
- import { Schema } from "@kyneta/schema"
40
-
41
- /**
42
- * Collaborative text (CRDT). Produces `annotated("text")`.
43
- *
44
- * The annotation implies scalar string semantics for reads,
45
- * but the Yjs substrate provides collaborative editing (insert, delete)
46
- * via Y.Text.
47
- *
48
- * This is a convenience re-export so that `@kyneta/yjs-schema` users
49
- * don't need to import `LoroSchema` just for `text()`.
50
- */
51
- export function text(): AnnotatedSchema<"text", undefined> {
52
- return Schema.annotated("text")
53
- }
54
-
55
- // Types (re-exported for convenience)
56
- export type { Changeset, Op, Ref, SubstratePayload } from "@kyneta/schema"
36
+ } from "@kyneta/schema"
57
37
 
58
38
  // ---------------------------------------------------------------------------
59
- // Low-level primitives — for power users and custom substrate compositions
39
+ // Yjs-specific exports
60
40
  // ---------------------------------------------------------------------------
61
41
 
62
- // Bind convenience wrapper for Yjs CRDT substrate
63
- export { bindYjs } from "./bind-yjs.js"
42
+ export type { YjsCaps } from "./bind-yjs.js"
43
+ // Namespace
44
+ export { yjs } from "./bind-yjs.js"
64
45
  // Change mapping
65
46
  export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
47
+ // NativeMap — the Yjs functor
48
+ export type { YjsNativeMap } from "./native-map.js"
66
49
  // Container creation
67
50
  export { ensureContainers } from "./populate.js"
68
51
  // Reader
69
52
  export { yjsReader } from "./reader.js"
70
53
  // Substrate
71
- export { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
54
+ export {
55
+ createYjsSubstrate,
56
+ yjsReplicaFactory,
57
+ yjsSubstrateFactory,
58
+ } from "./substrate.js"
72
59
  // Version
73
60
  export { YjsVersion } from "./version.js"
74
- // Escape hatch — access the underlying Y.Doc from a ref
75
- export { yjs } from "./yjs-escape.js"
76
61
  // Container resolution
77
62
  export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
@@ -0,0 +1,37 @@
1
+ // native-map — Yjs NativeMap functor.
2
+ //
3
+ // Maps schema kinds to Yjs shared types. Used as the `N`
4
+ // type parameter in `SchemaRef<S, M, N>` for Yjs-backed documents.
5
+
6
+ import type { NativeMap } from "@kyneta/schema"
7
+ import type * as Y from "yjs"
8
+
9
+ /**
10
+ * NativeMap for the Yjs CRDT substrate.
11
+ *
12
+ * Maps each schema kind to the corresponding Yjs shared type:
13
+ * - `root → Y.Doc` (the document itself)
14
+ * - `text → Y.Text`
15
+ * - `counter → undefined` (Yjs has no counter type)
16
+ * - `list → Y.Array<unknown>`
17
+ * - `movableList → undefined` (Yjs has no movable list)
18
+ * - `struct → Y.Map<unknown>` (Yjs uses maps for struct fields)
19
+ * - `map → Y.Map<unknown>`
20
+ * - `tree → undefined` (Yjs has no tree type)
21
+ * - `set → undefined` (not yet supported)
22
+ * - `scalar → undefined` (no container; stored in parent map)
23
+ * - `sum → undefined` (no container; stored in parent map)
24
+ */
25
+ export interface YjsNativeMap extends NativeMap {
26
+ readonly root: Y.Doc
27
+ readonly text: Y.Text
28
+ readonly counter: undefined
29
+ readonly list: Y.Array<unknown>
30
+ readonly movableList: undefined
31
+ readonly struct: Y.Map<unknown>
32
+ readonly map: Y.Map<unknown>
33
+ readonly tree: undefined
34
+ readonly set: undefined
35
+ readonly scalar: undefined
36
+ readonly sum: undefined
37
+ }
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 { STRUCTURAL_YJS_CLIENT_ID, 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,9 +26,9 @@ 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
33
  * When `conditional` is true, fields that already exist in the root map
34
34
  * are skipped. This is the correct mode after hydration — containers
@@ -45,7 +45,7 @@ import * as Y from "yjs"
45
45
  * structural ops across all peers, enabling Yjs deduplication on merge.
46
46
  *
47
47
  * @param doc - The Y.Doc to prepare
48
- * @param schema - The root document schema (typically annotated("doc", product))
48
+ * @param schema - The root document schema (a ProductSchema)
49
49
  * @param conditional - If true, skip fields that already exist in the root map.
50
50
  * Context: jj:smmulzkm (two-phase substrate construction)
51
51
  */
@@ -56,15 +56,7 @@ export function ensureContainers(
56
56
  ): void {
57
57
  const rootMap = doc.getMap("root")
58
58
 
59
- let rootProduct = schema
60
- while (
61
- rootProduct._kind === "annotated" &&
62
- rootProduct.schema !== undefined
63
- ) {
64
- rootProduct = rootProduct.schema
65
- }
66
-
67
- if (rootProduct._kind !== "product") {
59
+ if (schema[KIND] !== "product") {
68
60
  return
69
61
  }
70
62
 
@@ -75,7 +67,7 @@ export function ensureContainers(
75
67
 
76
68
  try {
77
69
  doc.transact(() => {
78
- for (const [key, fieldSchema] of Object.entries(rootProduct.fields).sort(
70
+ for (const [key, fieldSchema] of Object.entries(schema.fields).sort(
79
71
  ([a], [b]) => a.localeCompare(b),
80
72
  )) {
81
73
  if (conditional && rootMap.has(key)) continue
@@ -95,62 +87,36 @@ export function ensureContainers(
95
87
  /**
96
88
  * Ensure a root-level Yjs shared type exists for a schema field.
97
89
  *
98
- * Dispatches based on the schema annotation tag and structural kind:
99
- * - `annotated("text")` → empty Y.Text
100
- * - `annotated("counter")` → throws (unsupported in Yjs)
101
- * - `annotated("movable")` → throws (unsupported in Yjs)
102
- * - `annotated("tree")` → throws (unsupported in Yjs)
103
- * - `product` → empty Y.Map (recursive for nested products)
104
- * - `sequence` → empty Y.Array
105
- * - `map` → empty Y.Map
106
- * - `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)
107
97
  */
108
98
  function ensureRootField(
109
99
  rootMap: Y.Map<unknown>,
110
100
  key: string,
111
101
  fieldSchema: SchemaNode,
112
102
  ): void {
113
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
114
-
115
- switch (tag) {
103
+ switch (fieldSchema[KIND]) {
116
104
  case "text":
117
105
  rootMap.set(key, new Y.Text())
118
106
  return
119
107
 
120
- case "counter":
121
- throw new Error(
122
- `Yjs substrate does not support counter annotations. ` +
123
- `Use Schema.number() with ReplaceChange instead. ` +
124
- `Encountered counter annotation at root field "${key}".`,
125
- )
126
-
127
- case "movable":
128
- throw new Error(
129
- `Yjs substrate does not support movable list annotations. ` +
130
- `Yjs has no native movable list type. ` +
131
- `Encountered movable annotation at root field "${key}".`,
132
- )
133
-
134
- case "tree":
135
- throw new Error(
136
- `Yjs substrate does not support tree annotations. ` +
137
- `Yjs has no native tree type. ` +
138
- `Encountered tree annotation at root field "${key}".`,
139
- )
140
- }
141
-
142
- const structural = unwrapAnnotations(fieldSchema)
143
-
144
- switch (structural._kind) {
145
108
  case "product":
146
- rootMap.set(key, ensureMapContainers(structural))
109
+ rootMap.set(key, ensureMapContainers(fieldSchema))
147
110
  return
111
+
148
112
  case "sequence":
149
113
  rootMap.set(key, new Y.Array())
150
114
  return
115
+
151
116
  case "map":
152
117
  rootMap.set(key, new Y.Map())
153
118
  return
119
+
154
120
  case "scalar":
155
121
  case "sum": {
156
122
  // Plain values don't need shared type containers, but they DO
@@ -162,6 +128,16 @@ function ensureRootField(
162
128
  }
163
129
  return
164
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
+ )
165
141
  }
166
142
  }
167
143
 
@@ -175,37 +151,33 @@ function ensureRootField(
175
151
  *
176
152
  * Only creates containers for fields that require Yjs shared types
177
153
  * (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
178
- * Scalar and sum fields are left empty they'll be written as plain
179
- * values via change() when needed.
154
+ * Scalar and sum fields are set to their structural zero defaults.
180
155
  */
181
156
  function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
182
157
  const map = new Y.Map()
183
- const structural = unwrapAnnotations(schema)
184
158
 
185
- if (structural._kind !== "product") return map
159
+ if (schema[KIND] !== "product") return map
186
160
 
187
161
  for (const [key, fieldSchema] of Object.entries(
188
- structural.fields as Record<string, SchemaNode>,
162
+ schema.fields as Record<string, SchemaNode>,
189
163
  ).sort(([a], [b]) => a.localeCompare(b))) {
190
- const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
191
-
192
- if (tag === "text") {
193
- map.set(key, new Y.Text())
194
- continue
195
- }
196
-
197
- const fs = unwrapAnnotations(fieldSchema)
164
+ switch (fieldSchema[KIND]) {
165
+ case "text":
166
+ map.set(key, new Y.Text())
167
+ break
198
168
 
199
- switch (fs._kind) {
200
169
  case "product":
201
170
  map.set(key, ensureMapContainers(fieldSchema))
202
171
  break
172
+
203
173
  case "sequence":
204
174
  map.set(key, new Y.Array())
205
175
  break
176
+
206
177
  case "map":
207
178
  map.set(key, new Y.Map())
208
179
  break
180
+
209
181
  case "scalar":
210
182
  case "sum": {
211
183
  const zero = Zero.structural(fieldSchema)
@@ -214,23 +186,18 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
214
186
  }
215
187
  break
216
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
+ )
217
199
  }
218
200
  }
219
201
 
220
202
  return map
221
203
  }
222
-
223
- // ---------------------------------------------------------------------------
224
- // Helpers
225
- // ---------------------------------------------------------------------------
226
-
227
- /**
228
- * Unwrap annotation wrappers to reach the structural schema node.
229
- */
230
- function unwrapAnnotations(schema: SchemaNode): SchemaNode {
231
- let s = schema
232
- while (s._kind === "annotated" && s.schema !== undefined) {
233
- s = s.schema
234
- }
235
- return s
236
- }