@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/dist/index.d.ts +109 -147
- package/dist/index.js +321 -210
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/__tests__/bind-constraints.test.ts +333 -0
- package/src/__tests__/bind-yjs.test.ts +53 -55
- package/src/__tests__/create.test.ts +71 -62
- package/src/__tests__/{store-reader.test.ts → reader.test.ts} +64 -90
- package/src/__tests__/record-text-spike.test.ts +38 -31
- package/src/__tests__/structural-merge.test.ts +362 -0
- package/src/__tests__/substrate.test.ts +65 -84
- package/src/__tests__/version.test.ts +82 -16
- package/src/bind-yjs.ts +115 -64
- package/src/change-mapping.ts +60 -84
- package/src/create.ts +33 -28
- package/src/index.ts +32 -51
- package/src/populate.ts +87 -92
- package/src/{store-reader.ts → reader.ts} +7 -12
- package/src/substrate.ts +186 -42
- package/src/sync.ts +26 -26
- package/src/version.ts +57 -4
- package/src/yjs-resolve.ts +5 -21
- package/src/yjs-escape.ts +0 -100
package/src/bind-yjs.ts
CHANGED
|
@@ -1,36 +1,46 @@
|
|
|
1
|
-
// bind-yjs —
|
|
1
|
+
// bind-yjs — Yjs CRDT substrate namespace and factory.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// peer identity across all documents in an exchange.
|
|
3
|
+
// Provides the `yjs` substrate namespace (`yjs.bind()`, `yjs.replica()`,
|
|
4
|
+
// `yjs.unwrap()`) and the internal factory builder that injects a
|
|
5
|
+
// deterministic numeric Yjs clientID derived from the exchange's peerId.
|
|
7
6
|
//
|
|
8
7
|
// Yjs clientID is a uint32 number. We use FNV-1a hash truncated to
|
|
9
8
|
// 32 bits, mirroring the Loro binding's hashPeerId pattern but
|
|
10
9
|
// targeting Yjs's number type (not Loro's bigint/53-bit PeerID).
|
|
11
10
|
//
|
|
12
11
|
// Usage:
|
|
13
|
-
// import {
|
|
12
|
+
// import { yjs } from "@kyneta/yjs-schema"
|
|
14
13
|
//
|
|
15
|
-
// const TodoDoc =
|
|
16
|
-
// title: Schema.
|
|
14
|
+
// const TodoDoc = yjs.bind(Schema.struct({
|
|
15
|
+
// title: Schema.text(),
|
|
17
16
|
// items: Schema.list(Schema.struct({ name: Schema.string() })),
|
|
18
17
|
// }))
|
|
19
18
|
//
|
|
20
19
|
// const doc = exchange.get("my-doc", TodoDoc)
|
|
21
20
|
|
|
22
|
-
import { bind } from "@kyneta/schema"
|
|
23
|
-
import type { BoundSchema } from "@kyneta/schema"
|
|
24
|
-
import type { Schema as SchemaNode } from "@kyneta/schema"
|
|
25
21
|
import type {
|
|
22
|
+
CrdtStrategy,
|
|
23
|
+
Replica,
|
|
24
|
+
Schema as SchemaNode,
|
|
26
25
|
Substrate,
|
|
27
26
|
SubstrateFactory,
|
|
27
|
+
SubstrateNamespace,
|
|
28
28
|
SubstratePayload,
|
|
29
29
|
} from "@kyneta/schema"
|
|
30
|
+
import {
|
|
31
|
+
BACKING_DOC,
|
|
32
|
+
createSubstrateNamespace,
|
|
33
|
+
STRUCTURAL_YJS_CLIENT_ID,
|
|
34
|
+
unwrap,
|
|
35
|
+
} from "@kyneta/schema"
|
|
30
36
|
import * as Y from "yjs"
|
|
31
|
-
import { createYjsSubstrate } from "./substrate.js"
|
|
32
|
-
import { YjsVersion } from "./version.js"
|
|
33
37
|
import { ensureContainers } from "./populate.js"
|
|
38
|
+
import {
|
|
39
|
+
createYjsReplica,
|
|
40
|
+
createYjsSubstrate,
|
|
41
|
+
yjsReplicaFactory,
|
|
42
|
+
} from "./substrate.js"
|
|
43
|
+
import { YjsVersion } from "./version.js"
|
|
34
44
|
|
|
35
45
|
// ---------------------------------------------------------------------------
|
|
36
46
|
// Peer ID hashing — deterministic string → numeric Yjs clientID
|
|
@@ -55,7 +65,9 @@ function hashPeerId(peerId: string): number {
|
|
|
55
65
|
hash = Math.imul(hash, 0x01000193)
|
|
56
66
|
}
|
|
57
67
|
// Ensure unsigned 32-bit integer
|
|
58
|
-
|
|
68
|
+
const result = hash >>> 0
|
|
69
|
+
// Reserve 0 for structural ops — real peers never collide
|
|
70
|
+
return result === STRUCTURAL_YJS_CLIENT_ID ? 1 : result
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
// ---------------------------------------------------------------------------
|
|
@@ -67,36 +79,50 @@ function hashPeerId(peerId: string): number {
|
|
|
67
79
|
* on every new Y.Doc with a deterministic uint32 clientID derived
|
|
68
80
|
* from the exchange's string peerId.
|
|
69
81
|
*/
|
|
70
|
-
function createYjsFactory(
|
|
71
|
-
peerId: string,
|
|
72
|
-
): SubstrateFactory<YjsVersion> {
|
|
82
|
+
function createYjsFactory(peerId: string): SubstrateFactory<YjsVersion> {
|
|
73
83
|
const numericClientId = hashPeerId(peerId)
|
|
74
84
|
|
|
75
85
|
return {
|
|
86
|
+
replica: yjsReplicaFactory,
|
|
87
|
+
|
|
88
|
+
createReplica(): Replica<YjsVersion> {
|
|
89
|
+
// Default random clientID — safe for hydration (no local writes).
|
|
90
|
+
// Identity is set at upgrade() time, after hydration.
|
|
91
|
+
return createYjsReplica(new Y.Doc())
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
upgrade(
|
|
95
|
+
replica: Replica<YjsVersion>,
|
|
96
|
+
schema: SchemaNode,
|
|
97
|
+
): Substrate<YjsVersion> {
|
|
98
|
+
const doc = (replica as any)[BACKING_DOC] as Y.Doc
|
|
99
|
+
// Set stable identity AFTER hydration — avoids Yjs clientID
|
|
100
|
+
// conflict detection that would reassign to a random value.
|
|
101
|
+
doc.clientID = numericClientId
|
|
102
|
+
// Conditional ensureContainers: skip fields that already exist
|
|
103
|
+
// from hydrated state (each set() is a CRDT write).
|
|
104
|
+
ensureContainers(doc, schema, true)
|
|
105
|
+
return createYjsSubstrate(doc, schema)
|
|
106
|
+
},
|
|
107
|
+
|
|
76
108
|
create(schema: SchemaNode): Substrate<YjsVersion> {
|
|
109
|
+
// Fresh doc — set identity immediately, unconditional containers.
|
|
77
110
|
const doc = new Y.Doc()
|
|
78
111
|
doc.clientID = numericClientId
|
|
79
|
-
|
|
80
112
|
ensureContainers(doc, schema)
|
|
81
113
|
return createYjsSubstrate(doc, schema)
|
|
82
114
|
},
|
|
83
115
|
|
|
84
|
-
|
|
116
|
+
fromEntirety(
|
|
85
117
|
payload: SubstratePayload,
|
|
86
118
|
schema: SchemaNode,
|
|
87
119
|
): Substrate<YjsVersion> {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
}
|
|
96
|
-
const doc = new Y.Doc()
|
|
97
|
-
doc.clientID = numericClientId
|
|
98
|
-
Y.applyUpdate(doc, payload.data)
|
|
99
|
-
return createYjsSubstrate(doc, schema)
|
|
120
|
+
// Two-phase path: createReplica → merge → upgrade
|
|
121
|
+
// Identity is set at upgrade() time, after hydration —
|
|
122
|
+
// avoids Yjs clientID conflict detection.
|
|
123
|
+
const replica = this.createReplica()
|
|
124
|
+
replica.merge(payload)
|
|
125
|
+
return this.upgrade(replica, schema)
|
|
100
126
|
},
|
|
101
127
|
|
|
102
128
|
parseVersion(serialized: string): YjsVersion {
|
|
@@ -106,42 +132,67 @@ function createYjsFactory(
|
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
// ---------------------------------------------------------------------------
|
|
109
|
-
//
|
|
135
|
+
// yjs — the Yjs CRDT substrate namespace
|
|
110
136
|
// ---------------------------------------------------------------------------
|
|
111
137
|
|
|
112
138
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* This is the recommended way to declare a Yjs-backed document type.
|
|
116
|
-
* The factory builder injects a deterministic numeric Yjs clientID derived
|
|
117
|
-
* from the exchange's string peerId, ensuring consistent change attribution
|
|
118
|
-
* across all documents and sessions.
|
|
119
|
-
*
|
|
120
|
-
* **Unsupported annotations:** Yjs has no native counter, movable list,
|
|
121
|
-
* or tree types. Schemas passed to `bindYjs` must not contain
|
|
122
|
-
* `Schema.annotated("counter")`, `Schema.annotated("movable")`, or
|
|
123
|
-
* `Schema.annotated("tree")`. These will throw at construction time.
|
|
139
|
+
* The Yjs CRDT substrate namespace.
|
|
124
140
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
141
|
+
* - `yjs.bind(schema)` — collaborative sync (default)
|
|
142
|
+
* - `yjs.bind(schema, "ephemeral")` — ephemeral/presence broadcast
|
|
143
|
+
* - `yjs.replica()` — collaborative replication (default)
|
|
144
|
+
* - `yjs.replica("ephemeral")` — ephemeral replication
|
|
145
|
+
* - `yjs.unwrap(ref)` — access the underlying Y.Doc
|
|
129
146
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* items: Schema.list(Schema.struct({
|
|
133
|
-
* name: Schema.string(),
|
|
134
|
-
* done: Schema.boolean(),
|
|
135
|
-
* })),
|
|
136
|
-
* }))
|
|
137
|
-
*
|
|
138
|
-
* const doc = exchange.get("my-todos", TodoDoc)
|
|
139
|
-
* ```
|
|
147
|
+
* Strategy is constrained to `CrdtStrategy` (`"collaborative" | "ephemeral"`).
|
|
148
|
+
* Passing `"authoritative"` is a compile error.
|
|
140
149
|
*/
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
150
|
+
/** The closed set of capability tags that the Yjs substrate supports. */
|
|
151
|
+
export type YjsCaps = "text" | "json"
|
|
152
|
+
|
|
153
|
+
export const yjs: SubstrateNamespace<CrdtStrategy, YjsCaps> & {
|
|
154
|
+
/** Access the underlying `Y.Doc` backing a ref. */
|
|
155
|
+
unwrap(ref: object): Y.Doc
|
|
156
|
+
} = {
|
|
157
|
+
...createSubstrateNamespace<CrdtStrategy, YjsCaps>({
|
|
158
|
+
strategies: {
|
|
159
|
+
collaborative: {
|
|
160
|
+
factory: ctx => createYjsFactory(ctx.peerId),
|
|
161
|
+
replicaFactory: yjsReplicaFactory,
|
|
162
|
+
},
|
|
163
|
+
ephemeral: {
|
|
164
|
+
factory: ctx => createYjsFactory(ctx.peerId),
|
|
165
|
+
replicaFactory: yjsReplicaFactory,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
defaultStrategy: "collaborative",
|
|
169
|
+
}),
|
|
170
|
+
|
|
171
|
+
unwrap(ref: object): Y.Doc {
|
|
172
|
+
let substrate: any
|
|
173
|
+
try {
|
|
174
|
+
substrate = unwrap(ref)
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(
|
|
177
|
+
"yjs.unwrap() requires a ref backed by a Yjs substrate. " +
|
|
178
|
+
"Use a doc created by exchange.get() with a yjs.bind() schema, " +
|
|
179
|
+
"or by createYjsDoc().",
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const doc = substrate[BACKING_DOC]
|
|
184
|
+
if (
|
|
185
|
+
!doc ||
|
|
186
|
+
typeof doc !== "object" ||
|
|
187
|
+
typeof (doc as any).getMap !== "function" ||
|
|
188
|
+
typeof (doc as any).clientID !== "number"
|
|
189
|
+
) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"yjs.unwrap() requires a ref backed by a Yjs substrate. " +
|
|
192
|
+
"The ref has a substrate but it is not a Yjs substrate. " +
|
|
193
|
+
"Use a doc created with a yjs.bind() schema or createYjsDoc().",
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
return doc as Y.Doc
|
|
197
|
+
},
|
|
198
|
+
}
|
package/src/change-mapping.ts
CHANGED
|
@@ -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, KIND, RawPath } from "@kyneta/schema"
|
|
34
33
|
import * as Y from "yjs"
|
|
35
34
|
import { resolveYjsType } from "./yjs-resolve.js"
|
|
36
35
|
|
|
@@ -75,15 +74,15 @@ export function applyChangeToYjs(
|
|
|
75
74
|
|
|
76
75
|
case "increment":
|
|
77
76
|
throw new Error(
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
78
|
+
`Counter requires a CRDT backend that supports counters (e.g. Loro). ` +
|
|
80
79
|
`Attempted IncrementChange with amount=${(change as IncrementChange).amount} at path [${pathToString(path)}].`,
|
|
81
80
|
)
|
|
82
81
|
|
|
83
82
|
case "tree":
|
|
84
83
|
throw new Error(
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
`Yjs substrate does not support "${change.type}" changes. ` +
|
|
85
|
+
`Tree requires a CRDT backend that supports trees (e.g. Loro). ` +
|
|
87
86
|
`Attempted TreeChange at path [${pathToString(path)}].`,
|
|
88
87
|
)
|
|
89
88
|
|
|
@@ -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(
|
|
148
|
+
const yjsItems = items.map(item =>
|
|
150
149
|
maybeCreateSharedType(item, itemSchema),
|
|
151
150
|
)
|
|
152
151
|
resolved.insert(cursor, yjsItems)
|
|
@@ -251,26 +250,16 @@ function maybeCreateSharedType(
|
|
|
251
250
|
): unknown {
|
|
252
251
|
if (schema === undefined) return value
|
|
253
252
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
text
|
|
253
|
+
switch (schema[KIND]) {
|
|
254
|
+
// First-class text → Y.Text
|
|
255
|
+
case "text": {
|
|
256
|
+
const text = new Y.Text()
|
|
257
|
+
if (typeof value === "string" && value.length > 0) {
|
|
258
|
+
text.insert(0, value)
|
|
259
|
+
}
|
|
260
|
+
return text
|
|
262
261
|
}
|
|
263
|
-
return text
|
|
264
|
-
}
|
|
265
262
|
|
|
266
|
-
// Annotated counter/movable/tree → should not reach here (thrown earlier)
|
|
267
|
-
if (tag === "counter" || tag === "movable" || tag === "tree") {
|
|
268
|
-
throw new Error(
|
|
269
|
-
`Yjs substrate does not support "${tag}" annotations.`,
|
|
270
|
-
)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
switch (structural._kind) {
|
|
274
263
|
case "product": {
|
|
275
264
|
if (
|
|
276
265
|
value === null ||
|
|
@@ -280,17 +269,14 @@ function maybeCreateSharedType(
|
|
|
280
269
|
) {
|
|
281
270
|
return value
|
|
282
271
|
}
|
|
283
|
-
return createStructuredMap(
|
|
284
|
-
value as Record<string, unknown>,
|
|
285
|
-
structural,
|
|
286
|
-
)
|
|
272
|
+
return createStructuredMap(value as Record<string, unknown>, schema)
|
|
287
273
|
}
|
|
288
274
|
|
|
289
275
|
case "sequence": {
|
|
290
276
|
if (!Array.isArray(value)) return value
|
|
291
277
|
const arr = new Y.Array()
|
|
292
|
-
const itemSchema =
|
|
293
|
-
const items = (value as unknown[]).map(
|
|
278
|
+
const itemSchema = schema.item
|
|
279
|
+
const items = (value as unknown[]).map(item =>
|
|
294
280
|
maybeCreateSharedType(item, itemSchema),
|
|
295
281
|
)
|
|
296
282
|
arr.insert(0, items)
|
|
@@ -307,17 +293,26 @@ function maybeCreateSharedType(
|
|
|
307
293
|
return value
|
|
308
294
|
}
|
|
309
295
|
const map = new Y.Map()
|
|
310
|
-
const valueSchema =
|
|
311
|
-
for (const [k, v] of Object.entries(
|
|
312
|
-
value as Record<string, unknown>,
|
|
313
|
-
)) {
|
|
296
|
+
const valueSchema = schema.item
|
|
297
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
314
298
|
map.set(k, maybeCreateSharedType(v, valueSchema))
|
|
315
299
|
}
|
|
316
300
|
return map
|
|
317
301
|
}
|
|
318
302
|
|
|
303
|
+
// Unsupported first-class CRDT types — should not reach here
|
|
304
|
+
// (rejected at bind time by caps check)
|
|
305
|
+
case "counter":
|
|
306
|
+
case "set":
|
|
307
|
+
case "tree":
|
|
308
|
+
case "movable":
|
|
309
|
+
throw new Error(
|
|
310
|
+
`Yjs substrate does not support [KIND]="${schema[KIND]}". ` +
|
|
311
|
+
`This should have been caught at bind() time.`,
|
|
312
|
+
)
|
|
313
|
+
|
|
319
314
|
default:
|
|
320
|
-
// Scalar, sum
|
|
315
|
+
// Scalar, sum — return as plain value
|
|
321
316
|
return value
|
|
322
317
|
}
|
|
323
318
|
}
|
|
@@ -334,9 +329,8 @@ function createStructuredMap(
|
|
|
334
329
|
productSchema: SchemaNode,
|
|
335
330
|
): Y.Map<any> {
|
|
336
331
|
const map = new Y.Map()
|
|
337
|
-
const structural = unwrapAnnotations(productSchema)
|
|
338
332
|
|
|
339
|
-
if (
|
|
333
|
+
if (productSchema[KIND] !== "product") {
|
|
340
334
|
// Fallback: set all values as plain
|
|
341
335
|
for (const [key, val] of Object.entries(obj)) {
|
|
342
336
|
map.set(key, val)
|
|
@@ -347,28 +341,22 @@ function createStructuredMap(
|
|
|
347
341
|
// Process fields present in the value object
|
|
348
342
|
for (const [key, val] of Object.entries(obj)) {
|
|
349
343
|
if (val === undefined) continue
|
|
350
|
-
const fieldSchema =
|
|
351
|
-
const yjsVal = fieldSchema
|
|
352
|
-
? maybeCreateSharedType(val, fieldSchema)
|
|
353
|
-
: val
|
|
344
|
+
const fieldSchema = productSchema.fields[key]
|
|
345
|
+
const yjsVal = fieldSchema ? maybeCreateSharedType(val, fieldSchema) : val
|
|
354
346
|
map.set(key, yjsVal)
|
|
355
347
|
}
|
|
356
348
|
|
|
357
|
-
// Create shared types for
|
|
349
|
+
// Create shared types for first-class CRDT fields declared in the schema
|
|
358
350
|
// but missing from the value object. This ensures Yjs containers
|
|
359
351
|
// exist for later mutation (e.g. .insert() on a text field inside
|
|
360
352
|
// a struct inside a record/list).
|
|
361
353
|
for (const [key, fieldSchema] of Object.entries(
|
|
362
|
-
|
|
354
|
+
productSchema.fields as Record<string, SchemaNode>,
|
|
363
355
|
)) {
|
|
364
356
|
if (key in obj) continue // already processed above
|
|
365
|
-
|
|
366
|
-
fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
|
|
367
|
-
if (tag === "text") {
|
|
357
|
+
if (fieldSchema[KIND] === "text") {
|
|
368
358
|
map.set(key, new Y.Text())
|
|
369
359
|
}
|
|
370
|
-
// Other annotated container types (counter, movable, tree) are
|
|
371
|
-
// unsupported in Yjs and will throw if used elsewhere.
|
|
372
360
|
}
|
|
373
361
|
|
|
374
362
|
return map
|
|
@@ -391,7 +379,7 @@ function createStructuredMap(
|
|
|
391
379
|
*
|
|
392
380
|
* @param events - The events from the `observeDeep` callback
|
|
393
381
|
*/
|
|
394
|
-
export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
|
|
382
|
+
export function eventsToOps(events: Y.YEvent<any>[], schema: SchemaNode): Op[] {
|
|
395
383
|
const ops: Op[] = []
|
|
396
384
|
|
|
397
385
|
for (const event of events) {
|
|
@@ -402,7 +390,7 @@ export function eventsToOps(events: Y.YEvent<any>[]): Op[] {
|
|
|
402
390
|
}
|
|
403
391
|
}
|
|
404
392
|
|
|
405
|
-
return expandMapOpsToLeaves(ops)
|
|
393
|
+
return expandMapOpsToLeaves(ops, schema)
|
|
406
394
|
}
|
|
407
395
|
|
|
408
396
|
// ---------------------------------------------------------------------------
|
|
@@ -512,18 +500,16 @@ function mapEventToChange(event: Y.YEvent<any>): MapChange | null {
|
|
|
512
500
|
|
|
513
501
|
const target = event.target as Y.Map<any>
|
|
514
502
|
|
|
515
|
-
event.changes.keys.forEach(
|
|
516
|
-
(change
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
},
|
|
526
|
-
)
|
|
503
|
+
event.changes.keys.forEach((change: { action: string }, key: string) => {
|
|
504
|
+
if (change.action === "add" || change.action === "update") {
|
|
505
|
+
const value = target.get(key)
|
|
506
|
+
set[key] = extractEventValue(value)
|
|
507
|
+
hasSet = true
|
|
508
|
+
} else if (change.action === "delete") {
|
|
509
|
+
deleteKeys.push(key)
|
|
510
|
+
hasDelete = true
|
|
511
|
+
}
|
|
512
|
+
})
|
|
527
513
|
|
|
528
514
|
if (!hasSet && !hasDelete) return null
|
|
529
515
|
|
|
@@ -554,17 +540,6 @@ function extractEventValue(value: unknown): unknown {
|
|
|
554
540
|
// Schema helpers
|
|
555
541
|
// ---------------------------------------------------------------------------
|
|
556
542
|
|
|
557
|
-
/**
|
|
558
|
-
* Unwrap annotation wrappers to reach the structural schema node.
|
|
559
|
-
*/
|
|
560
|
-
function unwrapAnnotations(schema: SchemaNode): SchemaNode {
|
|
561
|
-
let s = schema
|
|
562
|
-
while (s._kind === "annotated" && s.schema !== undefined) {
|
|
563
|
-
s = s.schema
|
|
564
|
-
}
|
|
565
|
-
return s
|
|
566
|
-
}
|
|
567
|
-
|
|
568
543
|
/**
|
|
569
544
|
* Resolve the schema at a given path by walking through advanceSchema.
|
|
570
545
|
*/
|
|
@@ -580,8 +555,9 @@ function resolveSchemaAtPath(rootSchema: SchemaNode, path: Path): SchemaNode {
|
|
|
580
555
|
* Get the item schema from a sequence schema, if available.
|
|
581
556
|
*/
|
|
582
557
|
function getItemSchema(schema: SchemaNode): SchemaNode | undefined {
|
|
583
|
-
|
|
584
|
-
|
|
558
|
+
if (schema[KIND] === "sequence") return schema.item
|
|
559
|
+
if (schema[KIND] === "movable") return schema.item
|
|
560
|
+
return undefined
|
|
585
561
|
}
|
|
586
562
|
|
|
587
563
|
/**
|
|
@@ -591,12 +567,14 @@ function getFieldSchema(
|
|
|
591
567
|
schema: SchemaNode,
|
|
592
568
|
key: string,
|
|
593
569
|
): SchemaNode | undefined {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
570
|
+
if (schema[KIND] === "product") {
|
|
571
|
+
return schema.fields[key]
|
|
572
|
+
}
|
|
573
|
+
if (schema[KIND] === "map") {
|
|
574
|
+
return schema.item
|
|
597
575
|
}
|
|
598
|
-
if (
|
|
599
|
-
return
|
|
576
|
+
if (schema[KIND] === "set") {
|
|
577
|
+
return schema.item
|
|
600
578
|
}
|
|
601
579
|
return undefined
|
|
602
580
|
}
|
|
@@ -606,7 +584,5 @@ function getFieldSchema(
|
|
|
606
584
|
// ---------------------------------------------------------------------------
|
|
607
585
|
|
|
608
586
|
function pathToString(path: Path): string {
|
|
609
|
-
return path.segments
|
|
610
|
-
.map((seg) => String(seg.resolve()))
|
|
611
|
-
.join(".")
|
|
587
|
+
return path.segments.map(seg => String(seg.resolve())).join(".")
|
|
612
588
|
}
|
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 `
|
|
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`, `
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import {
|
|
17
|
+
import type {
|
|
18
|
+
Ref,
|
|
19
|
+
Schema as SchemaType,
|
|
20
|
+
Substrate,
|
|
21
|
+
SubstratePayload,
|
|
22
|
+
} from "@kyneta/schema"
|
|
23
|
+
import {
|
|
24
|
+
interpret,
|
|
25
|
+
observation,
|
|
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 `
|
|
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` / `
|
|
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/
|
|
45
|
-
"Use a doc created by createYjsDoc() or
|
|
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,12 +66,12 @@ 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/
|
|
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)
|
|
65
73
|
.with(writable)
|
|
66
|
-
.with(
|
|
74
|
+
.with(observation)
|
|
67
75
|
.done()
|
|
68
76
|
substrates.set(doc, substrate)
|
|
69
77
|
// Also register in the general unwrap() registry so that the
|
|
@@ -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,
|
|
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
|
-
//
|
|
153
|
+
// createYjsDocFromEntirety
|
|
149
154
|
// ---------------------------------------------------------------------------
|
|
150
155
|
|
|
151
|
-
type
|
|
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
|
|
162
|
+
* Reconstruct a live Yjs-backed document from a substrate entirety payload.
|
|
158
163
|
*
|
|
159
|
-
* The payload must have been produced by `
|
|
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 =
|
|
165
|
-
* const docB =
|
|
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
|
|
174
|
+
export const createYjsDocFromEntirety: CreateYjsDocFromEntirety = (
|
|
170
175
|
schema,
|
|
171
176
|
payload,
|
|
172
|
-
) => registerDoc(schema, yjsSubstrateFactory.
|
|
177
|
+
) => registerDoc(schema, yjsSubstrateFactory.fromEntirety(payload, schema))
|