@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/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,
|
|
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
|
-
// 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
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
export {
|
|
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")`,
|
|
30
|
-
* schema, and creates empty containers for each field within a
|
|
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
|
-
*
|
|
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
|
-
* @param schema - The root document schema (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
70
|
-
* - `
|
|
71
|
-
* - `
|
|
72
|
-
* - `
|
|
73
|
-
* - `
|
|
74
|
-
* - `
|
|
75
|
-
* - `
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
159
|
+
if (schema[KIND] !== "product") return map
|
|
157
160
|
|
|
158
161
|
for (const [key, fieldSchema] of Object.entries(
|
|
159
|
-
|
|
160
|
-
)) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 —
|
|
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
|
+
}
|