@kyneta/yjs-schema 1.0.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/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +865 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/__tests__/bind-yjs.test.ts +266 -0
- package/src/__tests__/create.test.ts +632 -0
- package/src/__tests__/record-text-spike.test.ts +429 -0
- package/src/__tests__/store-reader.test.ts +722 -0
- package/src/__tests__/substrate.test.ts +604 -0
- package/src/__tests__/version.test.ts +227 -0
- package/src/bind-yjs.ts +147 -0
- package/src/change-mapping.ts +612 -0
- package/src/create.ts +172 -0
- package/src/index.ts +83 -0
- package/src/populate.ts +208 -0
- package/src/store-reader.ts +123 -0
- package/src/substrate.ts +252 -0
- package/src/sync.ts +107 -0
- package/src/version.ts +138 -0
- package/src/yjs-escape.ts +100 -0
- package/src/yjs-resolve.ts +108 -0
package/src/create.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// create — batteries-included document construction backed by YjsSubstrate.
|
|
2
|
+
//
|
|
3
|
+
// Provides `createYjsDoc` and `createYjsDocFromSnapshot` functions that
|
|
4
|
+
// hide the interpret pipeline and layer composition behind a single call.
|
|
5
|
+
//
|
|
6
|
+
// Internally tracks substrates via a module-scoped WeakMap so that sync
|
|
7
|
+
// primitives (`version`, `exportSnapshot`, `importDelta` in sync.ts)
|
|
8
|
+
// can retrieve the substrate from just a doc ref.
|
|
9
|
+
//
|
|
10
|
+
// `getSubstrate` is exported for use by `sync.ts` but is NOT re-exported
|
|
11
|
+
// from the barrel (`index.ts`). It is an internal cross-module helper.
|
|
12
|
+
//
|
|
13
|
+
// Two forms for `createYjsDoc`:
|
|
14
|
+
// createYjsDoc(schema, yjsDoc) — "bring your own doc" (wrap existing)
|
|
15
|
+
// createYjsDoc(schema) — create a fresh empty Y.Doc
|
|
16
|
+
|
|
17
|
+
import { interpret, registerSubstrate } from "@kyneta/schema"
|
|
18
|
+
import { changefeed, readable, writable } from "@kyneta/schema"
|
|
19
|
+
import type { Ref } from "@kyneta/schema"
|
|
20
|
+
import type { Schema as SchemaType } from "@kyneta/schema"
|
|
21
|
+
import type { Substrate, SubstratePayload } from "@kyneta/schema"
|
|
22
|
+
import * as Y from "yjs"
|
|
23
|
+
import { YjsVersion } from "./version.js"
|
|
24
|
+
import { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Substrate tracking (module-scoped)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const substrates = new WeakMap<object, Substrate<YjsVersion>>()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Retrieve the substrate associated with a doc created by `createYjsDoc`
|
|
34
|
+
* or `createYjsDocFromSnapshot`.
|
|
35
|
+
*
|
|
36
|
+
* Exported for `sync.ts` — NOT re-exported from the barrel.
|
|
37
|
+
*
|
|
38
|
+
* @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromSnapshot`.
|
|
39
|
+
*/
|
|
40
|
+
export function getSubstrate(doc: object): Substrate<YjsVersion> {
|
|
41
|
+
const s = substrates.get(doc)
|
|
42
|
+
if (!s) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"version/exportSnapshot/importDelta called on an object without a YjsSubstrate. " +
|
|
45
|
+
"Use a doc created by createYjsDoc() or createYjsDocFromSnapshot().",
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return s
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// registerDoc — internal helper (interpret + WeakMap registration)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function registerDoc(
|
|
56
|
+
schema: SchemaType,
|
|
57
|
+
substrate: Substrate<YjsVersion>,
|
|
58
|
+
): any {
|
|
59
|
+
// The `as any` on the builder avoids TS2589 — interpret's fluent API
|
|
60
|
+
// produces deeply recursive types when S is the abstract SchemaType.
|
|
61
|
+
// The public createYjsDoc/createYjsDocFromSnapshot signatures provide
|
|
62
|
+
// the correct Ref<S> return type via interface call signature patterns.
|
|
63
|
+
const doc: any = (interpret as any)(schema, substrate.context())
|
|
64
|
+
.with(readable)
|
|
65
|
+
.with(writable)
|
|
66
|
+
.with(changefeed)
|
|
67
|
+
.done()
|
|
68
|
+
substrates.set(doc, substrate)
|
|
69
|
+
// Also register in the general unwrap() registry so that the
|
|
70
|
+
// yjs() escape hatch can discover the substrate from the ref.
|
|
71
|
+
registerSubstrate(doc, substrate)
|
|
72
|
+
return doc
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// isYDoc — runtime check for Y.Doc
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function isYDoc(value: unknown): value is Y.Doc {
|
|
80
|
+
return (
|
|
81
|
+
value !== null &&
|
|
82
|
+
value !== undefined &&
|
|
83
|
+
typeof value === "object" &&
|
|
84
|
+
"getMap" in value &&
|
|
85
|
+
"getText" in value &&
|
|
86
|
+
"getArray" in value &&
|
|
87
|
+
"transact" in value &&
|
|
88
|
+
typeof (value as any).transact === "function" &&
|
|
89
|
+
// Y.Doc has clientID; distinguish from other objects
|
|
90
|
+
"clientID" in value &&
|
|
91
|
+
typeof (value as any).clientID === "number"
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// createYjsDoc
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// Interface call signature avoids TS2589 on Ref<S> when S is generic.
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a live Yjs-backed document.
|
|
103
|
+
*
|
|
104
|
+
* **Form 1 — bring your own doc:**
|
|
105
|
+
* ```ts
|
|
106
|
+
* const yjsDoc = new Y.Doc()
|
|
107
|
+
* const doc = createYjsDoc(mySchema, yjsDoc)
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* **Form 2 — fresh empty doc:**
|
|
111
|
+
* ```ts
|
|
112
|
+
* const doc = createYjsDoc(mySchema)
|
|
113
|
+
*
|
|
114
|
+
* // Apply initial content via change():
|
|
115
|
+
* change(doc, d => {
|
|
116
|
+
* d.title.insert(0, "Hello")
|
|
117
|
+
* d.items.push({ name: "First item" })
|
|
118
|
+
* })
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* Returns a full-stack `Ref<S>` — callable, navigable, writable,
|
|
122
|
+
* transactable, and observable. Backed by a `YjsSubstrate` with
|
|
123
|
+
* CRDT collaboration support.
|
|
124
|
+
*
|
|
125
|
+
* The returned ref observes **all** mutations to the underlying Y.Doc,
|
|
126
|
+
* regardless of source (local kyneta writes, importDelta, external
|
|
127
|
+
* `Y.applyUpdate()`, external raw Yjs API mutations).
|
|
128
|
+
*
|
|
129
|
+
* @param schema - The schema describing the document structure.
|
|
130
|
+
* @param doc - Optional `Y.Doc` instance to wrap. If omitted, a fresh
|
|
131
|
+
* empty Y.Doc is created with containers matching the schema.
|
|
132
|
+
*/
|
|
133
|
+
type CreateYjsDoc = <S extends SchemaType>(
|
|
134
|
+
schema: S,
|
|
135
|
+
doc?: Y.Doc,
|
|
136
|
+
) => Ref<S>
|
|
137
|
+
|
|
138
|
+
export const createYjsDoc: CreateYjsDoc = (schema, doc) => {
|
|
139
|
+
if (doc !== undefined && isYDoc(doc)) {
|
|
140
|
+
// Bring your own doc — wrap the existing Y.Doc
|
|
141
|
+
return registerDoc(schema, createYjsSubstrate(doc, schema))
|
|
142
|
+
}
|
|
143
|
+
// Fresh empty doc
|
|
144
|
+
return registerDoc(schema, yjsSubstrateFactory.create(schema))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// createYjsDocFromSnapshot
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
type CreateYjsDocFromSnapshot = <S extends SchemaType>(
|
|
152
|
+
schema: S,
|
|
153
|
+
payload: SubstratePayload,
|
|
154
|
+
) => Ref<S>
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reconstruct a live Yjs-backed document from a substrate snapshot payload.
|
|
158
|
+
*
|
|
159
|
+
* The payload must have been produced by `exportSnapshot()` on a
|
|
160
|
+
* compatible document. This is the entry point for SSR hydration
|
|
161
|
+
* and reconnection past log compaction.
|
|
162
|
+
*
|
|
163
|
+
* ```ts
|
|
164
|
+
* const payload = exportSnapshot(docA)
|
|
165
|
+
* const docB = createYjsDocFromSnapshot(MySchema, payload)
|
|
166
|
+
* // docB has the same state as docA at the time of export
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export const createYjsDocFromSnapshot: CreateYjsDocFromSnapshot = (
|
|
170
|
+
schema,
|
|
171
|
+
payload,
|
|
172
|
+
) => registerDoc(schema, yjsSubstrateFactory.fromSnapshot(payload, schema))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @kyneta/yjs-schema — Yjs CRDT substrate for @kyneta/schema.
|
|
2
|
+
//
|
|
3
|
+
// Provides a Substrate<YjsVersion> implementation that wraps a Y.Doc
|
|
4
|
+
// with schema-aware typed reads, writes, versioning, and export/import.
|
|
5
|
+
//
|
|
6
|
+
// Batteries-included API (most users):
|
|
7
|
+
// createYjsDoc, createYjsDocFromSnapshot, version, exportSnapshot,
|
|
8
|
+
// exportSince, importDelta, change, subscribe, applyChanges
|
|
9
|
+
//
|
|
10
|
+
// Low-level primitives (power users):
|
|
11
|
+
// createYjsSubstrate, yjsSubstrateFactory, yjsStoreReader,
|
|
12
|
+
// resolveYjsType, stepIntoYjs, applyChangeToYjs, eventsToOps, YjsVersion
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Batteries-included API — one import, one createYjsDoc call, done
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
// Construction
|
|
19
|
+
export { createYjsDoc, createYjsDocFromSnapshot } from "./create.js"
|
|
20
|
+
|
|
21
|
+
// Sync primitives (Yjs-specific)
|
|
22
|
+
export {
|
|
23
|
+
exportSince,
|
|
24
|
+
exportSnapshot,
|
|
25
|
+
importDelta,
|
|
26
|
+
version,
|
|
27
|
+
} from "./sync.js"
|
|
28
|
+
|
|
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
|
+
// Types (re-exported for convenience)
|
|
55
|
+
export type { Changeset, Op, Ref, SubstratePayload } from "@kyneta/schema"
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Low-level primitives — for power users and custom substrate compositions
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
// Version
|
|
62
|
+
export { YjsVersion } from "./version.js"
|
|
63
|
+
|
|
64
|
+
// Store reader
|
|
65
|
+
export { yjsStoreReader } from "./store-reader.js"
|
|
66
|
+
|
|
67
|
+
// Container resolution
|
|
68
|
+
export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
|
|
69
|
+
|
|
70
|
+
// Change mapping
|
|
71
|
+
export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
|
|
72
|
+
|
|
73
|
+
// Container creation
|
|
74
|
+
export { ensureContainers } from "./populate.js"
|
|
75
|
+
|
|
76
|
+
// Substrate
|
|
77
|
+
export { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
|
|
78
|
+
|
|
79
|
+
// Bind — convenience wrapper for Yjs CRDT substrate
|
|
80
|
+
export { bindYjs } from "./bind-yjs.js"
|
|
81
|
+
|
|
82
|
+
// Escape hatch — access the underlying Y.Doc from a ref
|
|
83
|
+
export { yjs } from "./yjs-escape.js"
|
package/src/populate.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// populate — Yjs container creation from schema structure.
|
|
2
|
+
//
|
|
3
|
+
// Ensures that the correct Yjs shared types (Y.Text, Y.Array, Y.Map)
|
|
4
|
+
// exist in a Y.Doc's root map to match the schema structure, and that
|
|
5
|
+
// scalar/sum fields are initialized with Zero.structural defaults.
|
|
6
|
+
//
|
|
7
|
+
// This is NOT seed data — it's structural completeness, matching what
|
|
8
|
+
// PlainSubstrate does when it initializes its store with Zero.structural.
|
|
9
|
+
// The Yjs store reader expects to find values at every schema path;
|
|
10
|
+
// without this, unset scalars would return undefined instead of their
|
|
11
|
+
// type-correct zero ("", 0, false).
|
|
12
|
+
//
|
|
13
|
+
// Root container strategy: All schema fields are children of a single
|
|
14
|
+
// root `Y.Map` obtained via `doc.getMap("root")`. This root map holds
|
|
15
|
+
// shared types (Y.Text, Y.Array, Y.Map) and plain value slots uniformly.
|
|
16
|
+
|
|
17
|
+
import type { Schema as SchemaNode } from "@kyneta/schema"
|
|
18
|
+
import { Zero } from "@kyneta/schema"
|
|
19
|
+
import * as Y from "yjs"
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// ensureContainers — top-level entry point
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ensure that a Y.Doc's root map contains the correct Yjs shared types
|
|
27
|
+
* matching the schema structure.
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* No values are written — the containers are empty after this call.
|
|
34
|
+
* Initial content should be applied via `change()` after substrate
|
|
35
|
+
* construction.
|
|
36
|
+
*
|
|
37
|
+
* @param doc - The Y.Doc to prepare
|
|
38
|
+
* @param schema - The root document schema (typically annotated("doc", product))
|
|
39
|
+
*/
|
|
40
|
+
export function ensureContainers(doc: Y.Doc, schema: SchemaNode): void {
|
|
41
|
+
const rootMap = doc.getMap("root")
|
|
42
|
+
|
|
43
|
+
let rootProduct = schema
|
|
44
|
+
while (
|
|
45
|
+
rootProduct._kind === "annotated" &&
|
|
46
|
+
rootProduct.schema !== undefined
|
|
47
|
+
) {
|
|
48
|
+
rootProduct = rootProduct.schema
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (rootProduct._kind !== "product") {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
doc.transact(() => {
|
|
56
|
+
for (const [key, fieldSchema] of Object.entries(rootProduct.fields)) {
|
|
57
|
+
ensureRootField(rootMap, key, fieldSchema as SchemaNode)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// ensureRootField — create a single root-level container
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ensure a root-level Yjs shared type exists for a schema field.
|
|
68
|
+
*
|
|
69
|
+
* Dispatches based on the schema annotation tag and structural kind:
|
|
70
|
+
* - `annotated("text")` → empty Y.Text
|
|
71
|
+
* - `annotated("counter")` → throws (unsupported in Yjs)
|
|
72
|
+
* - `annotated("movable")` → throws (unsupported in Yjs)
|
|
73
|
+
* - `annotated("tree")` → throws (unsupported in Yjs)
|
|
74
|
+
* - `product` → empty Y.Map (recursive for nested products)
|
|
75
|
+
* - `sequence` → empty Y.Array
|
|
76
|
+
* - `map` → empty Y.Map
|
|
77
|
+
* - `scalar`/`sum` → no-op (plain values don't need containers)
|
|
78
|
+
*/
|
|
79
|
+
function ensureRootField(
|
|
80
|
+
rootMap: Y.Map<unknown>,
|
|
81
|
+
key: string,
|
|
82
|
+
fieldSchema: SchemaNode,
|
|
83
|
+
): void {
|
|
84
|
+
const tag = fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
|
|
85
|
+
|
|
86
|
+
switch (tag) {
|
|
87
|
+
case "text":
|
|
88
|
+
rootMap.set(key, new Y.Text())
|
|
89
|
+
return
|
|
90
|
+
|
|
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
|
+
case "product":
|
|
117
|
+
rootMap.set(key, ensureMapContainers(structural))
|
|
118
|
+
return
|
|
119
|
+
case "sequence":
|
|
120
|
+
rootMap.set(key, new Y.Array())
|
|
121
|
+
return
|
|
122
|
+
case "map":
|
|
123
|
+
rootMap.set(key, new Y.Map())
|
|
124
|
+
return
|
|
125
|
+
case "scalar":
|
|
126
|
+
case "sum": {
|
|
127
|
+
// Plain values don't need shared type containers, but they DO
|
|
128
|
+
// need structural zero defaults so the store reader returns
|
|
129
|
+
// type-correct values (e.g. "" not undefined for strings).
|
|
130
|
+
const zero = Zero.structural(fieldSchema)
|
|
131
|
+
if (zero !== undefined) {
|
|
132
|
+
rootMap.set(key, zero)
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// ensureMapContainers — recursively create nested Y.Map structure
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create an empty Y.Map with nested shared type children matching
|
|
145
|
+
* the product schema's field structure.
|
|
146
|
+
*
|
|
147
|
+
* Only creates containers for fields that require Yjs shared types
|
|
148
|
+
* (text → Y.Text, product → Y.Map, sequence → Y.Array, map → Y.Map).
|
|
149
|
+
* Scalar and sum fields are left empty — they'll be written as plain
|
|
150
|
+
* values via change() when needed.
|
|
151
|
+
*/
|
|
152
|
+
function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
|
|
153
|
+
const map = new Y.Map()
|
|
154
|
+
const structural = unwrapAnnotations(schema)
|
|
155
|
+
|
|
156
|
+
if (structural._kind !== "product") return map
|
|
157
|
+
|
|
158
|
+
for (const [key, fieldSchema] of Object.entries(
|
|
159
|
+
structural.fields as Record<string, SchemaNode>,
|
|
160
|
+
)) {
|
|
161
|
+
const tag =
|
|
162
|
+
fieldSchema._kind === "annotated" ? fieldSchema.tag : undefined
|
|
163
|
+
|
|
164
|
+
if (tag === "text") {
|
|
165
|
+
map.set(key, new Y.Text())
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const fs = unwrapAnnotations(fieldSchema)
|
|
170
|
+
|
|
171
|
+
switch (fs._kind) {
|
|
172
|
+
case "product":
|
|
173
|
+
map.set(key, ensureMapContainers(fieldSchema))
|
|
174
|
+
break
|
|
175
|
+
case "sequence":
|
|
176
|
+
map.set(key, new Y.Array())
|
|
177
|
+
break
|
|
178
|
+
case "map":
|
|
179
|
+
map.set(key, new Y.Map())
|
|
180
|
+
break
|
|
181
|
+
case "scalar":
|
|
182
|
+
case "sum": {
|
|
183
|
+
const zero = Zero.structural(fieldSchema)
|
|
184
|
+
if (zero !== undefined) {
|
|
185
|
+
map.set(key, zero)
|
|
186
|
+
}
|
|
187
|
+
break
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// store-reader — YjsStoreReader implementation.
|
|
2
|
+
//
|
|
3
|
+
// Implements StoreReader via schema-guided live navigation of the
|
|
4
|
+
// Yjs shared type tree. Each read operation resolves the shared type
|
|
5
|
+
// at the given path using resolveYjsType, then extracts the
|
|
6
|
+
// appropriate value based on `instanceof` discrimination.
|
|
7
|
+
//
|
|
8
|
+
// Y.Text → .toJSON() (string), Y.Map → .toJSON() (plain object),
|
|
9
|
+
// Y.Array → .toJSON() (plain array), plain values → as-is.
|
|
10
|
+
|
|
11
|
+
import type { StoreReader } from "@kyneta/schema"
|
|
12
|
+
import type { Path } from "@kyneta/schema"
|
|
13
|
+
import type { Schema as SchemaNode } from "@kyneta/schema"
|
|
14
|
+
import * as Y from "yjs"
|
|
15
|
+
import { resolveYjsType } from "./yjs-resolve.js"
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Value extraction
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract a plain value from a Yjs shared type or return a plain value as-is.
|
|
23
|
+
*
|
|
24
|
+
* - Y.Text → `.toJSON()` (string)
|
|
25
|
+
* - Y.Map → `.toJSON()` (plain object snapshot — for product/map reads)
|
|
26
|
+
* - Y.Array → `.toJSON()` (plain array snapshot)
|
|
27
|
+
* - Plain values (string, number, boolean, null) → returned as-is
|
|
28
|
+
*/
|
|
29
|
+
function extractValue(resolved: unknown): unknown {
|
|
30
|
+
if (resolved instanceof Y.Text) {
|
|
31
|
+
return resolved.toJSON()
|
|
32
|
+
}
|
|
33
|
+
if (resolved instanceof Y.Map) {
|
|
34
|
+
return resolved.toJSON()
|
|
35
|
+
}
|
|
36
|
+
if (resolved instanceof Y.Array) {
|
|
37
|
+
return resolved.toJSON()
|
|
38
|
+
}
|
|
39
|
+
// Plain scalar value (string, number, boolean, null, etc.)
|
|
40
|
+
return resolved
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// yjsStoreReader
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a StoreReader that navigates the Yjs shared type tree live,
|
|
49
|
+
* using the schema as a type witness to determine navigation at each
|
|
50
|
+
* path segment.
|
|
51
|
+
*
|
|
52
|
+
* The reader is a live view — mutations to the underlying Y.Doc
|
|
53
|
+
* (via `doc.transact()`, or `Y.applyUpdate()`) are immediately
|
|
54
|
+
* visible through the reader.
|
|
55
|
+
*
|
|
56
|
+
* Internally obtains the root map via `doc.getMap("root")`.
|
|
57
|
+
*
|
|
58
|
+
* @param doc - The Y.Doc to read from.
|
|
59
|
+
* @param schema - The root schema for the document.
|
|
60
|
+
*/
|
|
61
|
+
export function yjsStoreReader(
|
|
62
|
+
doc: Y.Doc,
|
|
63
|
+
schema: SchemaNode,
|
|
64
|
+
): StoreReader {
|
|
65
|
+
const rootMap = doc.getMap("root")
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
read(path: Path): unknown {
|
|
69
|
+
if (path.length === 0) {
|
|
70
|
+
// Root read — return the full root map as JSON
|
|
71
|
+
return rootMap.toJSON()
|
|
72
|
+
}
|
|
73
|
+
const resolved = resolveYjsType(rootMap, schema, path)
|
|
74
|
+
return extractValue(resolved)
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
arrayLength(path: Path): number {
|
|
78
|
+
const resolved = resolveYjsType(rootMap, schema, path)
|
|
79
|
+
if (resolved instanceof Y.Array) {
|
|
80
|
+
return resolved.length
|
|
81
|
+
}
|
|
82
|
+
// Graceful fallback for plain array values
|
|
83
|
+
if (Array.isArray(resolved)) {
|
|
84
|
+
return resolved.length
|
|
85
|
+
}
|
|
86
|
+
return 0
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
keys(path: Path): string[] {
|
|
90
|
+
const resolved = resolveYjsType(rootMap, schema, path)
|
|
91
|
+
if (resolved instanceof Y.Map) {
|
|
92
|
+
return Array.from(resolved.keys())
|
|
93
|
+
}
|
|
94
|
+
// Graceful fallback for plain object values
|
|
95
|
+
if (
|
|
96
|
+
resolved !== null &&
|
|
97
|
+
resolved !== undefined &&
|
|
98
|
+
typeof resolved === "object" &&
|
|
99
|
+
!Array.isArray(resolved)
|
|
100
|
+
) {
|
|
101
|
+
return Object.keys(resolved as Record<string, unknown>)
|
|
102
|
+
}
|
|
103
|
+
return []
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
hasKey(path: Path, key: string): boolean {
|
|
107
|
+
const resolved = resolveYjsType(rootMap, schema, path)
|
|
108
|
+
if (resolved instanceof Y.Map) {
|
|
109
|
+
return resolved.has(key)
|
|
110
|
+
}
|
|
111
|
+
// Graceful fallback for plain object values
|
|
112
|
+
if (
|
|
113
|
+
resolved !== null &&
|
|
114
|
+
resolved !== undefined &&
|
|
115
|
+
typeof resolved === "object" &&
|
|
116
|
+
!Array.isArray(resolved)
|
|
117
|
+
) {
|
|
118
|
+
return key in (resolved as Record<string, unknown>)
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|