@kyneta/yjs-schema 1.2.0 → 1.3.1

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/src/index.ts CHANGED
@@ -3,51 +3,49 @@
3
3
  // Provides a Substrate<YjsVersion> implementation that wraps a Y.Doc
4
4
  // with schema-aware typed reads, writes, versioning, and export/import.
5
5
  //
6
- // Batteries-included API (most users):
7
- // createYjsDoc, createYjsDocFromEntirety, version, exportEntirety,
8
- // exportSince, merge, change, subscribe, applyChanges
9
- //
10
- // Low-level primitives (power users):
11
- // createYjsSubstrate, yjsSubstrateFactory, yjsReader,
12
- // resolveYjsType, stepIntoYjs, applyChangeToYjs, eventsToOps, YjsVersion
6
+ // The single entry point is `createDoc(yjs.bind(schema))`. For the
7
+ // batteries-included API, import from this package. For the composable
8
+ // toolkit, import from `@kyneta/schema` directly.
13
9
 
14
10
  // ---------------------------------------------------------------------------
15
- // Batteries-included API one import, one createYjsDoc call, done
11
+ // Generic API (re-exported from @kyneta/schema for convenience)
16
12
  // ---------------------------------------------------------------------------
17
13
 
14
+ // Types (re-exported for convenience)
15
+ export type { Changeset } from "@kyneta/changefeed"
16
+ export type { DocRef, Op, Ref, SubstratePayload } from "@kyneta/schema"
17
+ // Construction
18
18
  // Mutation & observation (re-exported from @kyneta/schema for convenience)
19
19
  // Schema definition (re-exported for convenience)
20
+ // Native escape hatch
21
+ // Sync primitives (generic — work for any substrate)
20
22
  export {
21
23
  applyChanges,
22
24
  change,
23
- Schema,
24
- subscribe,
25
- subscribeNode,
26
- } from "@kyneta/schema"
27
- // Construction
28
- export { createYjsDoc, createYjsDocFromEntirety } from "./create.js"
29
- // Sync primitives (Yjs-specific)
30
- export {
25
+ createDoc,
26
+ createRef,
31
27
  exportEntirety,
32
28
  exportSince,
33
29
  merge,
30
+ NATIVE,
31
+ Schema,
32
+ subscribe,
33
+ subscribeNode,
34
+ unwrap,
34
35
  version,
35
- } from "./sync.js"
36
-
37
- // Types (re-exported for convenience)
38
- export type { Changeset } from "@kyneta/changefeed"
39
- export type { Op, Ref, SubstratePayload } from "@kyneta/schema"
36
+ } from "@kyneta/schema"
40
37
 
41
38
  // ---------------------------------------------------------------------------
42
- // Low-level primitives — for power users and custom substrate compositions
39
+ // Yjs-specific exports
43
40
  // ---------------------------------------------------------------------------
44
41
 
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
42
  export type { YjsCaps } from "./bind-yjs.js"
43
+ // Namespace
44
+ export { yjs } from "./bind-yjs.js"
49
45
  // Change mapping
50
46
  export { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
47
+ // NativeMap — the Yjs functor
48
+ export type { YjsNativeMap } from "./native-map.js"
51
49
  // Container creation
52
50
  export { ensureContainers } from "./populate.js"
53
51
  // Reader
@@ -61,4 +59,4 @@ export {
61
59
  // Version
62
60
  export { YjsVersion } from "./version.js"
63
61
  // Container resolution
64
- export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
62
+ export { resolveYjsType, stepIntoYjs } from "./yjs-resolve.js"
@@ -0,0 +1,37 @@
1
+ // native-map — Yjs NativeMap functor.
2
+ //
3
+ // Maps schema kinds to Yjs shared types. Used as the `N`
4
+ // type parameter in `SchemaRef<S, M, N>` for Yjs-backed documents.
5
+
6
+ import type { NativeMap } from "@kyneta/schema"
7
+ import type * as Y from "yjs"
8
+
9
+ /**
10
+ * NativeMap for the Yjs CRDT substrate.
11
+ *
12
+ * Maps each schema kind to the corresponding Yjs shared type:
13
+ * - `root → Y.Doc` (the document itself)
14
+ * - `text → Y.Text`
15
+ * - `counter → undefined` (Yjs has no counter type)
16
+ * - `list → Y.Array<unknown>`
17
+ * - `movableList → undefined` (Yjs has no movable list)
18
+ * - `struct → Y.Map<unknown>` (Yjs uses maps for struct fields)
19
+ * - `map → Y.Map<unknown>`
20
+ * - `tree → undefined` (Yjs has no tree type)
21
+ * - `set → undefined` (not yet supported)
22
+ * - `scalar → undefined` (no container; stored in parent map)
23
+ * - `sum → undefined` (no container; stored in parent map)
24
+ */
25
+ export interface YjsNativeMap extends NativeMap {
26
+ readonly root: Y.Doc
27
+ readonly text: Y.Text
28
+ readonly counter: undefined
29
+ readonly list: Y.Array<unknown>
30
+ readonly movableList: undefined
31
+ readonly struct: Y.Map<unknown>
32
+ readonly map: Y.Map<unknown>
33
+ readonly tree: undefined
34
+ readonly set: undefined
35
+ readonly scalar: undefined
36
+ readonly sum: undefined
37
+ }
package/src/populate.ts CHANGED
@@ -200,4 +200,4 @@ function ensureMapContainers(schema: SchemaNode): Y.Map<unknown> {
200
200
  }
201
201
 
202
202
  return map
203
- }
203
+ }
package/src/substrate.ts CHANGED
@@ -22,12 +22,18 @@ import type {
22
22
  SubstratePayload,
23
23
  WritableContext,
24
24
  } from "@kyneta/schema"
25
- import { BACKING_DOC, buildWritableContext, executeBatch } from "@kyneta/schema"
25
+ import {
26
+ BACKING_DOC,
27
+ buildWritableContext,
28
+ executeBatch,
29
+ KIND,
30
+ } from "@kyneta/schema"
26
31
  import * as Y from "yjs"
27
32
  import { applyChangeToYjs, eventsToOps } from "./change-mapping.js"
28
33
  import { ensureContainers } from "./populate.js"
29
34
  import { yjsReader } from "./reader.js"
30
35
  import { YjsVersion } from "./version.js"
36
+ import { resolveYjsType } from "./yjs-resolve.js"
31
37
 
32
38
  // ---------------------------------------------------------------------------
33
39
  // Origin tag — used to suppress echo from our own transactions
@@ -124,6 +130,17 @@ export function createYjsSubstrate(
124
130
  context(): WritableContext {
125
131
  if (!cachedCtx) {
126
132
  cachedCtx = buildWritableContext(substrate)
133
+ // Attach nativeResolver — used by interpretImpl to set [NATIVE]
134
+ // on every ref. The resolver maps schema positions to Yjs shared types.
135
+ ;(cachedCtx as any).nativeResolver = (
136
+ nodeSchema: SchemaNode,
137
+ path: { segments: readonly unknown[] },
138
+ ) => {
139
+ if (path.segments.length === 0) return doc
140
+ if (nodeSchema[KIND] === "scalar" || nodeSchema[KIND] === "sum")
141
+ return undefined
142
+ return resolveYjsType(rootMap, schema, path as any)
143
+ }
127
144
  }
128
145
  return cachedCtx
129
146
  },
@@ -252,9 +269,7 @@ export function createYjsSubstrate(
252
269
  */
253
270
  export function createYjsReplica(doc: Y.Doc): Replica<YjsVersion> {
254
271
  let currentDoc = doc
255
- let currentBase: YjsVersion = new YjsVersion(
256
- Y.encodeStateVector(new Y.Doc()),
257
- )
272
+ let currentBase: YjsVersion = new YjsVersion(Y.encodeStateVector(new Y.Doc()))
258
273
 
259
274
  return {
260
275
  get [BACKING_DOC]() {
package/src/version.ts CHANGED
@@ -12,30 +12,14 @@
12
12
  // decoded `Map<number, number>` (clientID → clock) maps ourselves.
13
13
 
14
14
  import type { Version } from "@kyneta/schema"
15
- import { versionVectorMeet } from "@kyneta/schema"
15
+ import {
16
+ base64ToUint8Array,
17
+ uint8ArrayToBase64,
18
+ versionVectorCompare,
19
+ versionVectorMeet,
20
+ } from "@kyneta/schema"
16
21
  import { decodeStateVector } from "yjs"
17
22
 
18
- // ---------------------------------------------------------------------------
19
- // Base64 helpers (platform-agnostic, no Node.js Buffer dependency)
20
- // ---------------------------------------------------------------------------
21
-
22
- function uint8ArrayToBase64(bytes: Uint8Array): string {
23
- let binary = ""
24
- for (let i = 0; i < bytes.length; i++) {
25
- binary += String.fromCharCode(bytes[i]!)
26
- }
27
- return btoa(binary)
28
- }
29
-
30
- function base64ToUint8Array(base64: string): Uint8Array {
31
- const binary = atob(base64)
32
- const bytes = new Uint8Array(binary.length)
33
- for (let i = 0; i < binary.length; i++) {
34
- bytes[i] = binary.charCodeAt(i)
35
- }
36
- return bytes
37
- }
38
-
39
23
  // ---------------------------------------------------------------------------
40
24
  // State vector encoding — manual varint (unsigned LEB128)
41
25
  // ---------------------------------------------------------------------------
@@ -105,54 +89,19 @@ export class YjsVersion implements Version {
105
89
  /**
106
90
  * Compare with another version using version-vector partial order.
107
91
  *
108
- * Decodes both state vectors via `Y.decodeStateVector()` to get
109
- * `Map<number, number>` (clientID clock), then compares:
92
+ * Delegates to the shared `versionVectorCompare` utility after decoding
93
+ * both state vectors via `Y.decodeStateVector()`.
110
94
  *
111
- * - Collect the union of all client IDs from both maps.
112
- * - For each client, compare clocks (missing client = clock 0).
113
- * - If all clocks in `this` ≤ `other` and at least one strictly less → `"behind"`
114
- * - If all clocks in `this` ≥ `other` and at least one strictly greater → `"ahead"`
115
- * - If all clocks equal → `"equal"`
116
- * - Otherwise → `"concurrent"`
117
- *
118
- * Throws if `other` is not a `YjsVersion`.
95
+ * @throws If `other` is not a `YjsVersion`.
119
96
  */
120
97
  compare(other: Version): "behind" | "equal" | "ahead" | "concurrent" {
121
98
  if (!(other instanceof YjsVersion)) {
122
99
  throw new Error("YjsVersion can only be compared with another YjsVersion")
123
100
  }
124
-
125
- const thisMap = decodeStateVector(this.sv)
126
- const otherMap = decodeStateVector(other.sv)
127
-
128
- // Collect the union of all client IDs
129
- const allClients = new Set<number>()
130
- for (const id of thisMap.keys()) allClients.add(id)
131
- for (const id of otherMap.keys()) allClients.add(id)
132
-
133
- let hasLess = false
134
- let hasGreater = false
135
-
136
- for (const clientId of allClients) {
137
- const thisClock = thisMap.get(clientId) ?? 0
138
- const otherClock = otherMap.get(clientId) ?? 0
139
-
140
- if (thisClock < otherClock) {
141
- hasLess = true
142
- }
143
- if (thisClock > otherClock) {
144
- hasGreater = true
145
- }
146
-
147
- // Early exit: if we've seen both less and greater, it's concurrent
148
- if (hasLess && hasGreater) {
149
- return "concurrent"
150
- }
151
- }
152
-
153
- if (hasLess && !hasGreater) return "behind"
154
- if (hasGreater && !hasLess) return "ahead"
155
- return "equal"
101
+ return versionVectorCompare(
102
+ decodeStateVector(this.sv),
103
+ decodeStateVector(other.sv),
104
+ )
156
105
  }
157
106
 
158
107
  /**
@@ -166,9 +115,7 @@ export class YjsVersion implements Version {
166
115
  */
167
116
  meet(other: Version): YjsVersion {
168
117
  if (!(other instanceof YjsVersion)) {
169
- throw new Error(
170
- "YjsVersion can only be meet'd with another YjsVersion",
171
- )
118
+ throw new Error("YjsVersion can only be meet'd with another YjsVersion")
172
119
  }
173
120
  const thisMap = decodeStateVector(this.sv)
174
121
  const otherMap = decodeStateVector(other.sv)
@@ -15,7 +15,7 @@
15
15
  // captures all mutations with correct relative paths.
16
16
 
17
17
  import type { Path, Schema as SchemaNode, Segment } from "@kyneta/schema"
18
- import { advanceSchema, KIND } from "@kyneta/schema"
18
+ import { advanceSchema } from "@kyneta/schema"
19
19
  import * as Y from "yjs"
20
20
 
21
21
  // ---------------------------------------------------------------------------
package/src/create.ts DELETED
@@ -1,177 +0,0 @@
1
- // create — batteries-included document construction backed by YjsSubstrate.
2
- //
3
- // Provides `createYjsDoc` and `createYjsDocFromEntirety` 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`, `exportEntirety`, `merge` 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 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"
31
- import { createYjsSubstrate, yjsSubstrateFactory } from "./substrate.js"
32
- import type { YjsVersion } from "./version.js"
33
-
34
- // ---------------------------------------------------------------------------
35
- // Substrate tracking (module-scoped)
36
- // ---------------------------------------------------------------------------
37
-
38
- const substrates = new WeakMap<object, Substrate<YjsVersion>>()
39
-
40
- /**
41
- * Retrieve the substrate associated with a doc created by `createYjsDoc`
42
- * or `createYjsDocFromEntirety`.
43
- *
44
- * Exported for `sync.ts` — NOT re-exported from the barrel.
45
- *
46
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
47
- */
48
- export function getSubstrate(doc: object): Substrate<YjsVersion> {
49
- const s = substrates.get(doc)
50
- if (!s) {
51
- throw new Error(
52
- "version/exportEntirety/merge called on an object without a YjsSubstrate. " +
53
- "Use a doc created by createYjsDoc() or createYjsDocFromEntirety().",
54
- )
55
- }
56
- return s
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // registerDoc — internal helper (interpret + WeakMap registration)
61
- // ---------------------------------------------------------------------------
62
-
63
- function registerDoc(
64
- schema: SchemaType,
65
- substrate: Substrate<YjsVersion>,
66
- ): any {
67
- // The `as any` on the builder avoids TS2589 — interpret's fluent API
68
- // produces deeply recursive types when S is the abstract SchemaType.
69
- // The public createYjsDoc/createYjsDocFromEntirety signatures provide
70
- // the correct Ref<S> return type via interface call signature patterns.
71
- const doc: any = (interpret as any)(schema, substrate.context())
72
- .with(readable)
73
- .with(writable)
74
- .with(observation)
75
- .done()
76
- substrates.set(doc, substrate)
77
- // Also register in the general unwrap() registry so that the
78
- // yjs() escape hatch can discover the substrate from the ref.
79
- registerSubstrate(doc, substrate)
80
- return doc
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // isYDoc — runtime check for Y.Doc
85
- // ---------------------------------------------------------------------------
86
-
87
- function isYDoc(value: unknown): value is Y.Doc {
88
- return (
89
- value !== null &&
90
- value !== undefined &&
91
- typeof value === "object" &&
92
- "getMap" in value &&
93
- "getText" in value &&
94
- "getArray" in value &&
95
- "transact" in value &&
96
- typeof (value as any).transact === "function" &&
97
- // Y.Doc has clientID; distinguish from other objects
98
- "clientID" in value &&
99
- typeof (value as any).clientID === "number"
100
- )
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // createYjsDoc
105
- // ---------------------------------------------------------------------------
106
-
107
- // Interface call signature avoids TS2589 on Ref<S> when S is generic.
108
-
109
- /**
110
- * Create a live Yjs-backed document.
111
- *
112
- * **Form 1 — bring your own doc:**
113
- * ```ts
114
- * const yjsDoc = new Y.Doc()
115
- * const doc = createYjsDoc(mySchema, yjsDoc)
116
- * ```
117
- *
118
- * **Form 2 — fresh empty doc:**
119
- * ```ts
120
- * const doc = createYjsDoc(mySchema)
121
- *
122
- * // Apply initial content via change():
123
- * change(doc, d => {
124
- * d.title.insert(0, "Hello")
125
- * d.items.push({ name: "First item" })
126
- * })
127
- * ```
128
- *
129
- * Returns a full-stack `Ref<S>` — callable, navigable, writable,
130
- * transactable, and observable. Backed by a `YjsSubstrate` with
131
- * CRDT collaboration support.
132
- *
133
- * The returned ref observes **all** mutations to the underlying Y.Doc,
134
- * regardless of source (local kyneta writes, merge, external
135
- * `Y.applyUpdate()`, external raw Yjs API mutations).
136
- *
137
- * @param schema - The schema describing the document structure.
138
- * @param doc - Optional `Y.Doc` instance to wrap. If omitted, a fresh
139
- * empty Y.Doc is created with containers matching the schema.
140
- */
141
- type CreateYjsDoc = <S extends SchemaType>(schema: S, doc?: Y.Doc) => Ref<S>
142
-
143
- export const createYjsDoc: CreateYjsDoc = (schema, doc) => {
144
- if (doc !== undefined && isYDoc(doc)) {
145
- // Bring your own doc — wrap the existing Y.Doc
146
- return registerDoc(schema, createYjsSubstrate(doc, schema))
147
- }
148
- // Fresh empty doc
149
- return registerDoc(schema, yjsSubstrateFactory.create(schema))
150
- }
151
-
152
- // ---------------------------------------------------------------------------
153
- // createYjsDocFromEntirety
154
- // ---------------------------------------------------------------------------
155
-
156
- type CreateYjsDocFromEntirety = <S extends SchemaType>(
157
- schema: S,
158
- payload: SubstratePayload,
159
- ) => Ref<S>
160
-
161
- /**
162
- * Reconstruct a live Yjs-backed document from a substrate entirety payload.
163
- *
164
- * The payload must have been produced by `exportEntirety()` on a
165
- * compatible document. This is the entry point for SSR hydration
166
- * and reconnection past log compaction.
167
- *
168
- * ```ts
169
- * const payload = exportEntirety(docA)
170
- * const docB = createYjsDocFromEntirety(MySchema, payload)
171
- * // docB has the same state as docA at the time of export
172
- * ```
173
- */
174
- export const createYjsDocFromEntirety: CreateYjsDocFromEntirety = (
175
- schema,
176
- payload,
177
- ) => registerDoc(schema, yjsSubstrateFactory.fromEntirety(payload, schema))
package/src/sync.ts DELETED
@@ -1,107 +0,0 @@
1
- // sync — sync primitives for YjsSubstrate-backed documents.
2
- //
3
- // These functions provide version tracking, entirety export, and merge
4
- // for documents created via `createYjsDoc` or
5
- // `createYjsDocFromEntirety`. They discover the substrate via the
6
- // module-scoped WeakMap in `create.ts`.
7
- //
8
- // Unlike PlainSubstrate's sync (which returns Op[] for deltas),
9
- // YjsSubstrate's sync uses binary SubstratePayload for both entireties
10
- // and deltas — these are Yjs's native state-as-update bytes.
11
-
12
- import type { SubstratePayload } from "@kyneta/schema"
13
- import { getSubstrate } from "./create.js"
14
- import type { YjsVersion } from "./version.js"
15
-
16
- // ---------------------------------------------------------------------------
17
- // version — current YjsVersion
18
- // ---------------------------------------------------------------------------
19
-
20
- /**
21
- * Current version as a `YjsVersion` (wrapping a Yjs state vector).
22
- *
23
- * Use `.serialize()` to get a text-safe string for embedding in HTML
24
- * meta tags, URL parameters, etc.
25
- *
26
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
27
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
28
- */
29
- export function version(doc: object): YjsVersion {
30
- return getSubstrate(doc).version()
31
- }
32
-
33
- // ---------------------------------------------------------------------------
34
- // exportEntirety — full state for reconstruction
35
- // ---------------------------------------------------------------------------
36
-
37
- /**
38
- * Export the full substrate entirety — sufficient for a new peer to
39
- * reconstruct an equivalent document via `createYjsDocFromEntirety()`.
40
- *
41
- * Returns a binary `SubstratePayload` (Yjs state-as-update bytes).
42
- *
43
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
44
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
45
- */
46
- export function exportEntirety(doc: object): SubstratePayload {
47
- return getSubstrate(doc).exportEntirety()
48
- }
49
-
50
- // ---------------------------------------------------------------------------
51
- // exportSince — delta since a version
52
- // ---------------------------------------------------------------------------
53
-
54
- /**
55
- * Export a delta payload containing all changes since the given version.
56
- *
57
- * Returns a binary `SubstratePayload` (Yjs update bytes), or `null`
58
- * if the delta cannot be computed.
59
- *
60
- * ```ts
61
- * const v0 = version(docA)
62
- * change(docA, d => d.title.insert(0, "Hi"))
63
- * const delta = exportSince(docA, v0)
64
- * merge(docB, delta!)
65
- * ```
66
- *
67
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
68
- * @param since - The version to diff from.
69
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
70
- */
71
- export function exportSince(
72
- doc: object,
73
- since: YjsVersion,
74
- ): SubstratePayload | null {
75
- return getSubstrate(doc).exportSince(since)
76
- }
77
-
78
- // ---------------------------------------------------------------------------
79
- // merge — apply a delta from another peer
80
- // ---------------------------------------------------------------------------
81
-
82
- /**
83
- * Import a delta payload into a live document.
84
- *
85
- * The payload must have been produced by `exportSince()` or
86
- * `exportEntirety()` on a compatible document.
87
- *
88
- * After import, the changefeed fires for all subscribers — the event
89
- * bridge handles this automatically.
90
- *
91
- * ```ts
92
- * const delta = exportSince(docA, sinceVersion)
93
- * merge(docB, delta!, "sync")
94
- * ```
95
- *
96
- * @param doc - A document created by `createYjsDoc` or `createYjsDocFromEntirety`.
97
- * @param payload - The delta or entirety payload to merge.
98
- * @param origin - Optional provenance tag for the changeset.
99
- * @throws If `doc` was not created by `createYjsDoc` / `createYjsDocFromEntirety`.
100
- */
101
- export function merge(
102
- doc: object,
103
- payload: SubstratePayload,
104
- origin?: string,
105
- ): void {
106
- getSubstrate(doc).merge(payload, origin)
107
- }