@kyneta/yjs-schema 1.3.0 → 1.4.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.
@@ -1,13 +1,14 @@
1
- // bind-constraints — compile-time and runtime tests for yjs.bind() caps enforcement.
1
+ // bind-constraints — compile-time and runtime tests for yjs.bind() laws enforcement.
2
2
  //
3
- // Verifies that `yjs.bind()` rejects schemas containing capabilities
4
- // that Yjs doesn't support (counter, movable, tree, set) at COMPILE TIME
5
- // via the `RestrictCaps` / `AllowedCaps` mechanism, while accepting
6
- // capabilities it does support (text) and plain schemas.
3
+ // Verifies that `yjs.bind()` rejects schemas containing composition laws
4
+ // that Yjs doesn't support (additive, positional-ot-move, tree-move,
5
+ // add-wins-per-key) at COMPILE TIME via the `RestrictLaws` / `AllowedLaws`
6
+ // mechanism, while accepting laws it does support (lww, lww-per-key,
7
+ // positional-ot, lww-tag-replaced) and plain schemas.
7
8
 
8
9
  import {
9
10
  type BoundSchema,
10
- type ExtractCaps,
11
+ type ExtractLaws,
11
12
  json,
12
13
  Schema,
13
14
  } from "@kyneta/schema"
@@ -105,36 +106,36 @@ describe("yjs.bind() accepts Yjs-compatible schemas", () => {
105
106
  // §2 — Compile-time rejection: schemas that yjs.bind() SHOULD reject
106
107
  // ===========================================================================
107
108
 
108
- describe("yjs.bind() rejects schemas with unsupported caps", () => {
109
- it("rejects counter", () => {
109
+ describe("yjs.bind() rejects schemas with unsupported composition laws", () => {
110
+ it("rejects counter (additive not in YjsLaws)", () => {
110
111
  const schema = Schema.struct({
111
112
  count: Schema.counter(),
112
113
  })
113
- // @ts-expect-error — counter is not in YjsCaps
114
+ // @ts-expect-error — "additive" is not in YjsLaws
114
115
  yjs.bind(schema)
115
116
  })
116
117
 
117
- it("rejects movableList", () => {
118
+ it("rejects movableList (positional-ot-move not in YjsLaws)", () => {
118
119
  const schema = Schema.struct({
119
120
  items: Schema.movableList(Schema.string()),
120
121
  })
121
- // @ts-expect-error — movable is not in YjsCaps
122
+ // @ts-expect-error — "positional-ot-move" is not in YjsLaws
122
123
  yjs.bind(schema)
123
124
  })
124
125
 
125
- it("rejects tree", () => {
126
+ it("rejects tree (tree-move not in YjsLaws)", () => {
126
127
  const schema = Schema.struct({
127
128
  hierarchy: Schema.tree(Schema.struct({ label: Schema.string() })),
128
129
  })
129
- // @ts-expect-error — tree is not in YjsCaps
130
+ // @ts-expect-error — "tree-move" is not in YjsLaws
130
131
  yjs.bind(schema)
131
132
  })
132
133
 
133
- it("rejects set", () => {
134
+ it("rejects set (add-wins-per-key not in YjsLaws)", () => {
134
135
  const schema = Schema.struct({
135
136
  tags: Schema.set(Schema.string()),
136
137
  })
137
- // @ts-expect-error — set is not in YjsCaps
138
+ // @ts-expect-error — "add-wins-per-key" is not in YjsLaws
138
139
  yjs.bind(schema)
139
140
  })
140
141
 
@@ -146,7 +147,7 @@ describe("yjs.bind() rejects schemas with unsupported caps", () => {
146
147
  }),
147
148
  ),
148
149
  })
149
- // @ts-expect-error — counter is deeply nested but still caught
150
+ // @ts-expect-error — "additive" is deeply nested but still caught
150
151
  yjs.bind(schema)
151
152
  })
152
153
 
@@ -155,7 +156,7 @@ describe("yjs.bind() rejects schemas with unsupported caps", () => {
155
156
  title: Schema.text(),
156
157
  views: Schema.counter(),
157
158
  })
158
- // @ts-expect-error — counter is not in YjsCaps
159
+ // @ts-expect-error — "additive" is not in YjsLaws
159
160
  yjs.bind(schema)
160
161
  })
161
162
 
@@ -163,7 +164,7 @@ describe("yjs.bind() rejects schemas with unsupported caps", () => {
163
164
  const schema = Schema.struct({
164
165
  scores: Schema.list(Schema.struct({ value: Schema.counter() })),
165
166
  })
166
- // @ts-expect-error — counter nested inside list struct
167
+ // @ts-expect-error — "additive" nested inside list struct
167
168
  yjs.bind(schema)
168
169
  })
169
170
  })
@@ -191,16 +192,24 @@ describe("cross-substrate: universal schema vs substrate-specific schema", () =>
191
192
  tasks: Schema.movableList(Schema.struct({ name: Schema.string() })),
192
193
  })
193
194
 
194
- it("universal schema is Yjs-compatible (ExtractCaps check)", () => {
195
- type Caps = ExtractCaps<typeof universalSchema>
196
- // Only "text" — in YjsCaps
197
- expectTypeOf<Caps>().toEqualTypeOf<"text">()
195
+ it("universal schema is Yjs-compatible (ExtractLaws check)", () => {
196
+ type Laws = ExtractLaws<typeof universalSchema>
197
+ // lww-per-key (struct), positional-ot (text, list), lww (scalars) all in YjsLaws
198
+ expectTypeOf<Laws>().toEqualTypeOf<
199
+ "lww-per-key" | "positional-ot" | "lww"
200
+ >()
198
201
  })
199
202
 
200
- it("Loro-specific schema is NOT Yjs-compatible (ExtractCaps check)", () => {
201
- type Caps = ExtractCaps<typeof loroSpecificSchema>
202
- // Includes "counter" and "movable" which are NOT in YjsCaps
203
- expectTypeOf<Caps>().toEqualTypeOf<"text" | "counter" | "movable">()
203
+ it("Loro-specific schema is NOT Yjs-compatible (ExtractLaws check)", () => {
204
+ type Laws = ExtractLaws<typeof loroSpecificSchema>
205
+ // Includes "additive" and "positional-ot-move" which are NOT in YjsLaws
206
+ expectTypeOf<Laws>().toEqualTypeOf<
207
+ | "lww-per-key"
208
+ | "positional-ot"
209
+ | "additive"
210
+ | "positional-ot-move"
211
+ | "lww"
212
+ >()
204
213
  })
205
214
 
206
215
  it("universal schema binds to yjs", () => {
@@ -210,11 +219,11 @@ describe("cross-substrate: universal schema vs substrate-specific schema", () =>
210
219
  })
211
220
 
212
221
  it("Loro-specific schema is rejected by yjs.bind()", () => {
213
- // @ts-expect-error — counter and movable not in YjsCaps
222
+ // @ts-expect-error — "additive" and "positional-ot-move" not in YjsLaws
214
223
  yjs.bind(loroSpecificSchema)
215
224
  })
216
225
 
217
- it("json.bind() accepts schemas with all caps (AllowedCaps = string)", () => {
226
+ it("json.bind() accepts schemas with all laws (AllowedLaws = string)", () => {
218
227
  const schema = Schema.struct({
219
228
  title: Schema.text(),
220
229
  count: Schema.counter(),
@@ -262,7 +271,7 @@ describe("bind constraint edge cases", () => {
262
271
  ]),
263
272
  hits: Schema.counter(),
264
273
  })
265
- // @ts-expect-error — counter taints the whole schema
274
+ // @ts-expect-error — "additive" taints the whole schema
266
275
  yjs.bind(schema)
267
276
  })
268
277
 
@@ -276,7 +285,7 @@ describe("bind constraint edge cases", () => {
276
285
  expect(bound).toBeDefined()
277
286
  })
278
287
 
279
- it("plain-only schema (no caps at all) is accepted", () => {
288
+ it("plain-only schema (no laws at all) is accepted", () => {
280
289
  const schema = Schema.struct({
281
290
  name: Schema.string(),
282
291
  age: Schema.number(),
@@ -1,15 +1,25 @@
1
1
  import {
2
2
  change,
3
3
  createRef,
4
+ deriveIdentity,
4
5
  exportEntirety,
5
6
  RawPath,
6
7
  Schema,
8
+ SYNC_COLLABORATIVE,
7
9
  unwrap,
8
10
  } from "@kyneta/schema"
9
11
  import { describe, expect, it } from "vitest"
10
12
  import * as Y from "yjs"
11
13
  import { yjs } from "../bind-yjs.js"
12
14
 
15
+ // ===========================================================================
16
+ // Identity-keying helpers
17
+ // ===========================================================================
18
+
19
+ function id(fieldName: string): string {
20
+ return deriveIdentity(fieldName, 1)
21
+ }
22
+
13
23
  // ===========================================================================
14
24
  // Helper — createDoc using the generic API
15
25
  // ===========================================================================
@@ -45,11 +55,11 @@ describe("yjs.bind", () => {
45
55
  // -------------------------------------------------------------------------
46
56
 
47
57
  describe("BoundSchema", () => {
48
- it("creates BoundSchema with collaborative strategy", () => {
58
+ it("creates BoundSchema with collaborative sync protocol", () => {
49
59
  const bound = yjs.bind(TodoSchema)
50
60
  expect(bound._brand).toBe("BoundSchema")
51
61
  expect(bound.schema).toBe(TodoSchema)
52
- expect(bound.strategy).toBe("collaborative")
62
+ expect(bound.syncProtocol).toEqual(SYNC_COLLABORATIVE)
53
63
  })
54
64
 
55
65
  it("has a factory builder function", () => {
@@ -70,7 +80,10 @@ describe("yjs.bind", () => {
70
80
  describe("factory builder", () => {
71
81
  it("produces a working SubstrateFactory", () => {
72
82
  const bound = yjs.bind(SimpleSchema)
73
- const factory = bound.factory({ peerId: "peer-1" })
83
+ const factory = bound.factory({
84
+ peerId: "peer-1",
85
+ binding: bound.identityBinding,
86
+ })
74
87
 
75
88
  const _substrate = factory.create(SimpleSchema)
76
89
 
@@ -87,7 +100,10 @@ describe("yjs.bind", () => {
87
100
 
88
101
  it("factory supports fromEntirety", () => {
89
102
  const bound = yjs.bind(SimpleSchema)
90
- const factory = bound.factory({ peerId: "peer-1" })
103
+ const factory = bound.factory({
104
+ peerId: "peer-1",
105
+ binding: bound.identityBinding,
106
+ })
91
107
 
92
108
  // Create and populate
93
109
  const doc1 = createYjsDocFromFactory(factory, SimpleSchema)
@@ -105,7 +121,10 @@ describe("yjs.bind", () => {
105
121
 
106
122
  it("factory supports parseVersion", () => {
107
123
  const bound = yjs.bind(SimpleSchema)
108
- const factory = bound.factory({ peerId: "peer-1" })
124
+ const factory = bound.factory({
125
+ peerId: "peer-1",
126
+ binding: bound.identityBinding,
127
+ })
109
128
 
110
129
  const substrate = factory.create(SimpleSchema)
111
130
  const v = substrate.version()
@@ -122,7 +141,10 @@ describe("yjs.bind", () => {
122
141
  describe("deterministic clientID", () => {
123
142
  it("same peerId produces same clientID across multiple factory calls", () => {
124
143
  const bound = yjs.bind(SimpleSchema)
125
- const factory = bound.factory({ peerId: "stable-peer-id" })
144
+ const factory = bound.factory({
145
+ peerId: "stable-peer-id",
146
+ binding: bound.identityBinding,
147
+ })
126
148
 
127
149
  const _s1 = factory.create(SimpleSchema)
128
150
  const _s2 = factory.create(SimpleSchema)
@@ -140,8 +162,14 @@ describe("yjs.bind", () => {
140
162
 
141
163
  it("different peerIds produce different clientIDs", () => {
142
164
  const bound = yjs.bind(SimpleSchema)
143
- const factory1 = bound.factory({ peerId: "peer-alpha" })
144
- const factory2 = bound.factory({ peerId: "peer-beta" })
165
+ const factory1 = bound.factory({
166
+ peerId: "peer-alpha",
167
+ binding: bound.identityBinding,
168
+ })
169
+ const factory2 = bound.factory({
170
+ peerId: "peer-beta",
171
+ binding: bound.identityBinding,
172
+ })
145
173
 
146
174
  const doc1 = unwrap(
147
175
  createYjsDocFromFactory(factory1, SimpleSchema),
@@ -155,7 +183,10 @@ describe("yjs.bind", () => {
155
183
 
156
184
  it("clientID is a valid uint32", () => {
157
185
  const bound = yjs.bind(SimpleSchema)
158
- const factory = bound.factory({ peerId: "test-peer-id-12345" })
186
+ const factory = bound.factory({
187
+ peerId: "test-peer-id-12345",
188
+ binding: bound.identityBinding,
189
+ })
159
190
  const doc = unwrap(
160
191
  createYjsDocFromFactory(factory, SimpleSchema),
161
192
  ) as Y.Doc
@@ -171,13 +202,19 @@ describe("yjs.bind", () => {
171
202
 
172
203
  // Simulate two separate "sessions" — both should hash to the same value
173
204
  const bound1 = yjs.bind(SimpleSchema)
174
- const factory1 = bound1.factory({ peerId })
205
+ const factory1 = bound1.factory({
206
+ peerId,
207
+ binding: bound1.identityBinding,
208
+ })
175
209
  const doc1 = unwrap(
176
210
  createYjsDocFromFactory(factory1, SimpleSchema),
177
211
  ) as Y.Doc
178
212
 
179
213
  const bound2 = yjs.bind(SimpleSchema)
180
- const factory2 = bound2.factory({ peerId })
214
+ const factory2 = bound2.factory({
215
+ peerId,
216
+ binding: bound2.identityBinding,
217
+ })
181
218
  const doc2 = unwrap(
182
219
  createYjsDocFromFactory(factory2, SimpleSchema),
183
220
  ) as Y.Doc
@@ -200,7 +237,7 @@ describe("yjs.bind", () => {
200
237
  const yjsDoc = unwrap(doc) as Y.Doc
201
238
 
202
239
  expect(yjsDoc).toBeInstanceOf(Y.Doc)
203
- expect(yjsDoc.getMap("root").get("count")).toBe(0)
240
+ expect(yjsDoc.getMap("root").get(id("count"))).toBe(0)
204
241
  })
205
242
 
206
243
  it("returns a Y.Doc with the correct root map state", () => {
@@ -212,8 +249,8 @@ describe("yjs.bind", () => {
212
249
  const yjsDoc = unwrap(doc) as Y.Doc
213
250
  const rootMap = yjsDoc.getMap("root")
214
251
 
215
- expect((rootMap.get("title") as Y.Text).toJSON()).toBe("Hello")
216
- expect(rootMap.get("count")).toBe(42)
252
+ expect((rootMap.get(id("title")) as Y.Text).toJSON()).toBe("Hello")
253
+ expect(rootMap.get(id("count"))).toBe(42)
217
254
  })
218
255
 
219
256
  it("returns undefined for non-refs (plain object)", () => {
@@ -238,7 +275,7 @@ describe("yjs.bind", () => {
238
275
  const yjsDoc = unwrap(doc) as Y.Doc
239
276
 
240
277
  // Mutate via raw Yjs
241
- yjsDoc.getMap("root").set("count", 99)
278
+ yjsDoc.getMap("root").set(id("count"), 99)
242
279
  expect(doc.count()).toBe(99)
243
280
  })
244
281
 
@@ -249,7 +286,7 @@ describe("yjs.bind", () => {
249
286
  })
250
287
  const yjsDoc = unwrap(doc) as Y.Doc
251
288
 
252
- const text = yjsDoc.getMap("root").get("title") as Y.Text
289
+ const text = yjsDoc.getMap("root").get(id("title")) as Y.Text
253
290
  text.insert(5, " World")
254
291
  expect(doc.title()).toBe("Hello World")
255
292
  })