@convex-localfirst/yjs 0.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/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/useCollaborativeDoc.d.ts +51 -0
- package/dist/useCollaborativeDoc.js +86 -0
- package/dist/yjsSync.d.ts +6 -0
- package/dist/yjsSync.js +46 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fanzzzd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @convex-localfirst/yjs
|
|
2
|
+
|
|
3
|
+
Ship a [Yjs](https://github.com/yjs/yjs) CRDT (rich text, nested lists) over the
|
|
4
|
+
convex-localfirst append-only log: each Yjs update is one insert-only row, so concurrent
|
|
5
|
+
rich-text edits **merge** instead of last-writer-wins clobbering. Includes a framework-
|
|
6
|
+
agnostic base64 codec, snapshot compaction, and a React `useCollaborativeDoc` hook.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @convex-localfirst/yjs
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Peer dependencies: `react`, `yjs`. MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// @convex-localfirst/yjs — wire a Yjs CRDT (rich text, nested lists) onto a
|
|
2
|
+
// local-first append-only log. The codec is framework-agnostic; the hook is React.
|
|
3
|
+
export { bytesToBase64, base64ToBytes, applyUpdateSafe, makeSnapshot, REMOTE_ORIGIN } from "./yjsSync.js";
|
|
4
|
+
export { useCollaborativeDoc } from "./useCollaborativeDoc.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
/** One persisted Yjs update row, as your backend stores it. `_id` is any stable
|
|
3
|
+
* per-row identity (used to dedupe applied rows); `update` is a base64 update. */
|
|
4
|
+
export interface CollaborativeDocRow {
|
|
5
|
+
readonly _id: string;
|
|
6
|
+
readonly update: string;
|
|
7
|
+
}
|
|
8
|
+
export interface UseCollaborativeDocOptions {
|
|
9
|
+
/** Document identity — the Y.Doc is keyed on this alone, so it must uniquely identify the
|
|
10
|
+
* document across EVERY scope this hook is used in. If your ids are only unique per
|
|
11
|
+
* workspace/project, pass a composite (e.g. `` `${workspaceId}:${docId}` ``) or the doc
|
|
12
|
+
* could merge rows across scopes. Changing it rebuilds the Y.Doc; also pass `key={docId}`
|
|
13
|
+
* to the editor component so it remounts. */
|
|
14
|
+
readonly docId: string;
|
|
15
|
+
/** Live rows for THIS document, from your own reactive query. The hook applies EVERY row you
|
|
16
|
+
* pass — it has no docId field to filter on — so scope/`.where` your query to exactly this
|
|
17
|
+
* document; a stray row from another doc would merge into this Y.Doc. Pass a stable reference
|
|
18
|
+
* when unchanged (a local-first `useLiveQuery` does this) so the apply effect only fires on new rows. */
|
|
19
|
+
readonly updates: ReadonlyArray<CollaborativeDocRow>;
|
|
20
|
+
/** Persist a local edit as a new insert-only row. Receives the base64 update;
|
|
21
|
+
* bind your docId/scope in the closure. May return a promise (awaited during compaction). */
|
|
22
|
+
readonly append: (updateBase64: string) => unknown;
|
|
23
|
+
/** Delete a row subsumed by a snapshot. Provide it to enable compaction; omit to disable —
|
|
24
|
+
* then both the row log AND the in-memory applied-set grow unbounded over the doc's lifetime.
|
|
25
|
+
* May return a promise. */
|
|
26
|
+
readonly prune?: (rowId: string) => unknown;
|
|
27
|
+
/** Compact once the row count crosses this many (default 50). No effect without `prune`. */
|
|
28
|
+
readonly compactThreshold?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A Y.Doc whose content syncs through a local-first append-only log: every Yjs
|
|
32
|
+
* update is one insert-only row. Local edits become rows (via `append`); rows from
|
|
33
|
+
* other clients (or this client's reload) are applied back into the Y.Doc. Offline
|
|
34
|
+
* edits queue as pending rows and flush on reconnect — fully local-first.
|
|
35
|
+
*
|
|
36
|
+
* Backend-agnostic: YOU supply the live `updates` and the `append`/`prune` callbacks,
|
|
37
|
+
* so it works with any table name / scope shape. Bind an editor to the returned doc,
|
|
38
|
+
* e.g. BlockNote → `doc.getXmlFragment("blocknote")`, TipTap → a `y-prosemirror` binding.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* const updates = useLiveQuery(collection("doc_updates").scope({ workspaceId }).where(u => u.docId === docId)) ?? [];
|
|
42
|
+
* const appendRow = useMutation(api.docUpdates.append);
|
|
43
|
+
* const pruneRow = useMutation(api.docUpdates.prune);
|
|
44
|
+
* const doc = useCollaborativeDoc({
|
|
45
|
+
* docId, updates,
|
|
46
|
+
* append: (update) => appendRow({ workspaceId, docId, update }).local,
|
|
47
|
+
* prune: (id) => pruneRow({ id }).local
|
|
48
|
+
* });
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare function useCollaborativeDoc(options: UseCollaborativeDocOptions): Y.Doc;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { REMOTE_ORIGIN, applyUpdateSafe, bytesToBase64, makeSnapshot } from "./yjsSync.js";
|
|
4
|
+
/**
|
|
5
|
+
* A Y.Doc whose content syncs through a local-first append-only log: every Yjs
|
|
6
|
+
* update is one insert-only row. Local edits become rows (via `append`); rows from
|
|
7
|
+
* other clients (or this client's reload) are applied back into the Y.Doc. Offline
|
|
8
|
+
* edits queue as pending rows and flush on reconnect — fully local-first.
|
|
9
|
+
*
|
|
10
|
+
* Backend-agnostic: YOU supply the live `updates` and the `append`/`prune` callbacks,
|
|
11
|
+
* so it works with any table name / scope shape. Bind an editor to the returned doc,
|
|
12
|
+
* e.g. BlockNote → `doc.getXmlFragment("blocknote")`, TipTap → a `y-prosemirror` binding.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const updates = useLiveQuery(collection("doc_updates").scope({ workspaceId }).where(u => u.docId === docId)) ?? [];
|
|
16
|
+
* const appendRow = useMutation(api.docUpdates.append);
|
|
17
|
+
* const pruneRow = useMutation(api.docUpdates.prune);
|
|
18
|
+
* const doc = useCollaborativeDoc({
|
|
19
|
+
* docId, updates,
|
|
20
|
+
* append: (update) => appendRow({ workspaceId, docId, update }).local,
|
|
21
|
+
* prune: (id) => pruneRow({ id }).local
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useCollaborativeDoc(options) {
|
|
26
|
+
const { docId, updates, compactThreshold = 50 } = options;
|
|
27
|
+
// Latest callbacks via a ref so the update-handler effect doesn't re-subscribe on
|
|
28
|
+
// every render when the caller passes fresh closures.
|
|
29
|
+
const cbs = useRef(options);
|
|
30
|
+
cbs.current = options;
|
|
31
|
+
// Fresh Y.Doc (and applied-set) per document. Pairing them in one memo keeps the
|
|
32
|
+
// dedup set tied to the exact doc instance it tracks.
|
|
33
|
+
const { doc, applied } = useMemo(() => ({ doc: new Y.Doc(), applied: new Set() }), [docId]);
|
|
34
|
+
useEffect(() => () => doc.destroy(), [doc]);
|
|
35
|
+
// Apply not-yet-applied rows (initial hydrate + live remote edits). Applying in any
|
|
36
|
+
// order, or the same row twice, is safe (Yjs is a CRDT). REMOTE_ORIGIN marks these
|
|
37
|
+
// so our own "update" handler below doesn't re-broadcast them as new rows.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
for (const u of updates) {
|
|
40
|
+
if (applied.has(u._id))
|
|
41
|
+
continue;
|
|
42
|
+
applied.add(u._id); // mark seen even on failure — a corrupt row is permanently bad
|
|
43
|
+
applyUpdateSafe(doc, u.update, REMOTE_ORIGIN);
|
|
44
|
+
}
|
|
45
|
+
}, [updates, doc, applied]);
|
|
46
|
+
// Compaction: when the row count crosses the threshold, write one snapshot row (the
|
|
47
|
+
// whole doc state) and prune the rows it subsumes. Runs AFTER the apply effect above
|
|
48
|
+
// (so `doc` already reflects every row we're about to subsume). Guarded so it fires
|
|
49
|
+
// once per crossing; safe to race with other clients.
|
|
50
|
+
const compacting = useRef(false);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const prune = cbs.current.prune;
|
|
53
|
+
if (!prune || compacting.current || updates.length <= compactThreshold)
|
|
54
|
+
return;
|
|
55
|
+
compacting.current = true;
|
|
56
|
+
const subsumed = updates.map((u) => u._id); // every row now folded into `doc`
|
|
57
|
+
const snapshot = makeSnapshot(doc); // = the full state these rows produced
|
|
58
|
+
void (async () => {
|
|
59
|
+
try {
|
|
60
|
+
// Snapshot first (lower row position) so any peer that sees the deletes also
|
|
61
|
+
// sees the snapshot that preserves their content.
|
|
62
|
+
await cbs.current.append(snapshot);
|
|
63
|
+
await Promise.all(subsumed.map((id) => prune(id)));
|
|
64
|
+
// Drop dedup entries for pruned rows so `applied` can't grow without bound across
|
|
65
|
+
// a doc's lifetime (the rows are gone; re-applying would be a no-op anyway).
|
|
66
|
+
for (const id of subsumed)
|
|
67
|
+
applied.delete(id);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
compacting.current = false;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
}, [updates, doc, applied, compactThreshold]);
|
|
74
|
+
// Persist local edits as rows. Skip REMOTE-origin updates (those just came from the
|
|
75
|
+
// apply effect) — otherwise we'd echo every remote edit back as a new row.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const handler = (update, origin) => {
|
|
78
|
+
if (origin === REMOTE_ORIGIN)
|
|
79
|
+
return;
|
|
80
|
+
void cbs.current.append(bytesToBase64(update));
|
|
81
|
+
};
|
|
82
|
+
doc.on("update", handler);
|
|
83
|
+
return () => doc.off("update", handler);
|
|
84
|
+
}, [doc]);
|
|
85
|
+
return doc;
|
|
86
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
export declare function bytesToBase64(bytes: Uint8Array): string;
|
|
3
|
+
export declare function base64ToBytes(b64: string): Uint8Array;
|
|
4
|
+
export declare const REMOTE_ORIGIN: unique symbol;
|
|
5
|
+
export declare function makeSnapshot(doc: Y.Doc): string;
|
|
6
|
+
export declare function applyUpdateSafe(doc: Y.Doc, base64: string, origin: unknown): boolean;
|
package/dist/yjsSync.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
// The glue that lets a Yjs CRDT ride an append-only local-first log.
|
|
3
|
+
//
|
|
4
|
+
// A Yjs document emits binary "updates" on every change. We store each update as
|
|
5
|
+
// ONE insert-only row (base64 string). Yjs updates are commutative + idempotent,
|
|
6
|
+
// so rows delivered in any order, at least once, always converge to the same
|
|
7
|
+
// document — which is exactly what an append-only, no-conflict row stream gives.
|
|
8
|
+
// Isomorphic base64 <-> bytes. The op log serializes values as JSON strings, so
|
|
9
|
+
// binary updates travel as base64. A simple per-byte loop (no fromCharCode.apply)
|
|
10
|
+
// avoids call-stack limits on large updates.
|
|
11
|
+
export function bytesToBase64(bytes) {
|
|
12
|
+
let bin = "";
|
|
13
|
+
for (let i = 0; i < bytes.length; i++)
|
|
14
|
+
bin += String.fromCharCode(bytes[i]);
|
|
15
|
+
return btoa(bin);
|
|
16
|
+
}
|
|
17
|
+
export function base64ToBytes(b64) {
|
|
18
|
+
const bin = atob(b64);
|
|
19
|
+
const out = new Uint8Array(bin.length);
|
|
20
|
+
for (let i = 0; i < bin.length; i++)
|
|
21
|
+
out[i] = bin.charCodeAt(i);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
// Tags updates we applied FROM the row stream so the Y.Doc "update" handler does
|
|
25
|
+
// not re-append them as new rows (which would loop forever). Any non-local origin
|
|
26
|
+
// value works; a stable symbol is unambiguous.
|
|
27
|
+
export const REMOTE_ORIGIN = Symbol("convex-localfirst-remote");
|
|
28
|
+
// A compaction snapshot: the entire doc state encoded as ONE update. Applied to a
|
|
29
|
+
// fresh Y.Doc it reproduces the full document, so it can replace (subsume) all the
|
|
30
|
+
// incremental update rows merged into `doc` so far.
|
|
31
|
+
export function makeSnapshot(doc) {
|
|
32
|
+
return bytesToBase64(Y.encodeStateAsUpdate(doc));
|
|
33
|
+
}
|
|
34
|
+
// Apply one base64 update, isolating failures: a single corrupt or incompatible
|
|
35
|
+
// row (bad base64, a truncated/garbage update, a future-format update) must NOT
|
|
36
|
+
// throw and brick the whole document — skip it and keep the rest. Returns whether
|
|
37
|
+
// it applied, so callers can still mark it "seen" and not retry a permanently-bad row.
|
|
38
|
+
export function applyUpdateSafe(doc, base64, origin) {
|
|
39
|
+
try {
|
|
40
|
+
Y.applyUpdate(doc, base64ToBytes(base64), origin);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@convex-localfirst/yjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Yjs CRDT over the convex-localfirst append-only log: base64 codec, snapshot compaction, useCollaborativeDoc hook.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"convex",
|
|
8
|
+
"local-first",
|
|
9
|
+
"yjs",
|
|
10
|
+
"crdt",
|
|
11
|
+
"collaborative"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": ">=18.0.0",
|
|
30
|
+
"yjs": ">=13.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@testing-library/dom": "^10.4.1",
|
|
34
|
+
"@testing-library/react": "^16.3.2",
|
|
35
|
+
"@types/react": "^19.0.0",
|
|
36
|
+
"@types/react-dom": "^19.0.0",
|
|
37
|
+
"jsdom": "^29.1.1",
|
|
38
|
+
"react": "^19.2.7",
|
|
39
|
+
"react-dom": "^19.2.7",
|
|
40
|
+
"typescript": "^5.7.0",
|
|
41
|
+
"vitest": "^2.1.0",
|
|
42
|
+
"yjs": "^13.6.31"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.json --noEmit false --emitDeclarationOnly false",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
47
|
+
"test": "vitest run --passWithNoTests"
|
|
48
|
+
}
|
|
49
|
+
}
|