@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/dist/index.d.ts +110 -108
- package/dist/index.js +171 -109
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/bind-yjs.test.ts +19 -19
- package/src/__tests__/create.test.ts +61 -51
- package/src/__tests__/{store-reader.test.ts → reader.test.ts} +30 -33
- package/src/__tests__/record-text-spike.test.ts +29 -21
- package/src/__tests__/structural-merge.test.ts +362 -0
- package/src/__tests__/substrate.test.ts +48 -64
- package/src/__tests__/version.test.ts +7 -16
- package/src/bind-yjs.ts +46 -25
- package/src/change-mapping.ts +20 -35
- package/src/create.ts +32 -27
- package/src/index.ts +24 -30
- package/src/populate.ts +42 -14
- package/src/{store-reader.ts → reader.ts} +7 -12
- package/src/substrate.ts +139 -40
- package/src/sync.ts +26 -26
- package/src/version.ts +2 -4
- package/src/yjs-escape.ts +19 -35
- package/src/yjs-resolve.ts +4 -10
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
|
-
|
|
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
|
-
|
|
111
|
+
fromEntirety(
|
|
85
112
|
payload: SubstratePayload,
|
|
86
113
|
schema: SchemaNode,
|
|
87
114
|
): 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)
|
|
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:
|
|
165
|
+
factory: ctx => createYjsFactory(ctx.peerId),
|
|
145
166
|
strategy: "causal",
|
|
146
167
|
})
|
|
147
|
-
}
|
|
168
|
+
}
|
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, 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(
|
|
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(
|
|
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
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
+
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 `
|
|
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,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/
|
|
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,
|
|
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))
|
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,
|
|
8
|
-
// exportSince,
|
|
7
|
+
// createYjsDoc, createYjsDocFromEntirety, version, exportEntirety,
|
|
8
|
+
// exportSince, merge, change, subscribe, applyChanges
|
|
9
9
|
//
|
|
10
10
|
// Low-level primitives (power users):
|
|
11
|
-
// createYjsSubstrate, yjsSubstrateFactory,
|
|
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,
|
|
20
|
-
|
|
28
|
+
export { createYjsDoc, createYjsDocFromEntirety } from "./create.js"
|
|
21
29
|
// Sync primitives (Yjs-specific)
|
|
22
30
|
export {
|
|
31
|
+
exportEntirety,
|
|
23
32
|
exportSince,
|
|
24
|
-
|
|
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
|
-
//
|
|
62
|
-
export {
|
|
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
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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(
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 —
|
|
1
|
+
// store-reader — YjsReader implementation.
|
|
2
2
|
//
|
|
3
|
-
// Implements
|
|
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 {
|
|
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
|
-
//
|
|
42
|
+
// yjsReader
|
|
45
43
|
// ---------------------------------------------------------------------------
|
|
46
44
|
|
|
47
45
|
/**
|
|
48
|
-
* Creates a
|
|
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
|
|
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
|
+
}
|