@voltro/local-first 0.52.0 → 0.54.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/CHANGELOG.md +424 -0
- package/README.md +1 -1
- package/THIRD-PARTY-NOTICES.md +29 -1
- package/dist/editor.d.ts +125 -0
- package/dist/editor.js +53 -0
- package/dist/index.d.ts +368 -27
- package/dist/index.js +192 -31
- package/dist/react.d.ts +174 -10
- package/dist/react.js +143 -71
- package/dist/{room-wFNUwEgi.js → room-24dGMM80.js} +119 -57
- package/package.json +26 -4
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { a as e, c as t, d as n, f as r,
|
|
1
|
+
import { a as e, c as t, d as n, f as r, g as i, h as a, i as o, l as s, m as c, n as l, o as u, p as d, r as f, s as p, t as m, u as h } from "./room-24dGMM80.js";
|
|
2
2
|
//#region src/conflict/policy.ts
|
|
3
|
-
var
|
|
3
|
+
var g = (e) => {
|
|
4
4
|
if (e.writer !== void 0) return `w:${e.writer}`;
|
|
5
5
|
try {
|
|
6
6
|
return `v:${JSON.stringify(e.value)}`;
|
|
7
7
|
} catch {
|
|
8
8
|
return `v:${String(e.value)}`;
|
|
9
9
|
}
|
|
10
|
-
},
|
|
10
|
+
}, _ = (e, t) => t.updatedAt > e.updatedAt ? t.value : t.updatedAt < e.updatedAt ? e.value : g(t) > g(e) ? t.value : e.value, v = (e) => e === "lastWriteWins" ? _ : e, y = (e) => {
|
|
11
11
|
let t = (t, n, r) => {
|
|
12
12
|
let i = e[t];
|
|
13
|
-
return (i === void 0 ?
|
|
13
|
+
return (i === void 0 ? _ : v(i))(n, r);
|
|
14
14
|
};
|
|
15
15
|
return {
|
|
16
16
|
resolveField: t,
|
|
@@ -31,32 +31,32 @@ var h = (e) => {
|
|
|
31
31
|
return r;
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
-
},
|
|
34
|
+
}, b = (e, t) => `${e}\u0001${t}`, x = () => {
|
|
35
35
|
let e = /* @__PURE__ */ new Map(), t = [], n = /* @__PURE__ */ new Set(), r = (e) => e.slice();
|
|
36
36
|
return {
|
|
37
37
|
kind: "in-memory",
|
|
38
38
|
loadCrdtState: (t, n) => {
|
|
39
|
-
let i = e.get(
|
|
39
|
+
let i = e.get(b(t, n));
|
|
40
40
|
return Promise.resolve(i === void 0 ? void 0 : r(i));
|
|
41
41
|
},
|
|
42
|
-
saveCrdtState: (t, n, i) => (e.set(
|
|
42
|
+
saveCrdtState: (t, n, i) => (e.set(b(t, n), r(i)), Promise.resolve()),
|
|
43
43
|
loadQueue: () => Promise.resolve(t.map((e) => ({ ...e }))),
|
|
44
44
|
saveQueue: (e) => (t = e.map((e) => ({ ...e })), Promise.resolve()),
|
|
45
|
-
pin: (e, t) => (n.add(
|
|
46
|
-
isPinned: (e, t) => Promise.resolve(n.has(
|
|
45
|
+
pin: (e, t) => (n.add(b(e, t)), Promise.resolve()),
|
|
46
|
+
isPinned: (e, t) => Promise.resolve(n.has(b(e, t)))
|
|
47
47
|
};
|
|
48
|
-
},
|
|
48
|
+
}, S = "voltro_local_first", C = "\0queue", w = "\0pin\0", T = (e, t) => `cell\u0000${e}\u0000${t}`, E = (e, t) => `${w}${e}\u0000${t}`, D = (e) => new Promise((t, n) => {
|
|
49
49
|
e.onsuccess = () => t(e.result), e.onerror = () => n(e.error ?? /* @__PURE__ */ Error("IndexedDB request failed"));
|
|
50
|
-
}),
|
|
50
|
+
}), O = async (e) => {
|
|
51
51
|
let t = e?.factory ?? globalThis.indexedDB;
|
|
52
52
|
if (t === void 0) throw Error("createIndexedDbPersistence: no IndexedDB available. Pass options.factory (e.g. in a non-browser host) or use createInMemoryPersistence().");
|
|
53
53
|
let n = e?.databaseName ?? "voltro-local-first", r = t.open(n, 1), i = await new Promise((e, t) => {
|
|
54
54
|
r.onupgradeneeded = () => {
|
|
55
55
|
let e = r.result;
|
|
56
|
-
e.objectStoreNames.contains(
|
|
56
|
+
e.objectStoreNames.contains(S) || e.createObjectStore(S);
|
|
57
57
|
}, r.onsuccess = () => e(r.result), r.onerror = () => t(r.error ?? /* @__PURE__ */ Error("IndexedDB open failed"));
|
|
58
|
-
}), a = async (e) =>
|
|
59
|
-
await
|
|
58
|
+
}), a = async (e) => D(i.transaction(S, "readonly").objectStore(S).get(e)), o = async (e, t) => {
|
|
59
|
+
await D(i.transaction(S, "readwrite").objectStore(S).put(t, e));
|
|
60
60
|
}, s = (e) => {
|
|
61
61
|
if (e instanceof Uint8Array) return e.slice();
|
|
62
62
|
if (e instanceof ArrayBuffer) return new Uint8Array(e.slice(0));
|
|
@@ -64,33 +64,198 @@ var h = (e) => {
|
|
|
64
64
|
};
|
|
65
65
|
return {
|
|
66
66
|
kind: "indexeddb",
|
|
67
|
-
loadCrdtState: async (e, t) => s(await a(
|
|
67
|
+
loadCrdtState: async (e, t) => s(await a(T(e, t))),
|
|
68
68
|
saveCrdtState: async (e, t, n) => {
|
|
69
|
-
await o(
|
|
69
|
+
await o(T(e, t), n.slice());
|
|
70
70
|
},
|
|
71
71
|
loadQueue: async () => {
|
|
72
|
-
let e = await a(
|
|
72
|
+
let e = await a(C);
|
|
73
73
|
return e === void 0 || !Array.isArray(e.writes) ? [] : e.writes.map((e) => ({ ...e }));
|
|
74
74
|
},
|
|
75
75
|
saveQueue: async (e) => {
|
|
76
76
|
let t = { writes: e.map((e) => ({ ...e })) };
|
|
77
|
-
await o(
|
|
77
|
+
await o(C, t);
|
|
78
78
|
},
|
|
79
79
|
pin: async (e, t) => {
|
|
80
|
-
await o(
|
|
80
|
+
await o(E(e, t), !0);
|
|
81
81
|
},
|
|
82
|
-
isPinned: async (e, t) => await a(
|
|
82
|
+
isPinned: async (e, t) => await a(E(e, t)) === !0
|
|
83
83
|
};
|
|
84
|
-
},
|
|
84
|
+
}, k = (e) => e.inFlight === void 0 ? e.pending : [e.inFlight, ...e.pending], A = async (e, t = !0) => {
|
|
85
85
|
let n = await e.loadQueue(), r = {
|
|
86
|
-
...
|
|
86
|
+
...p(t),
|
|
87
87
|
pending: n
|
|
88
88
|
};
|
|
89
89
|
return {
|
|
90
90
|
getState: () => r,
|
|
91
|
-
dispatch: async (t) => (r =
|
|
91
|
+
dispatch: async (t) => (r = s(r, t), await e.saveQueue(k(r)), r)
|
|
92
92
|
};
|
|
93
|
-
},
|
|
93
|
+
}, j = (e) => {
|
|
94
|
+
let t = e;
|
|
95
|
+
return typeof t == "object" && !!t && typeof t.id == "string" && typeof t.tag == "string" && (t.status === "pending" || t.status === "sent" || t.status === "failed" || t.status === "conflict");
|
|
96
|
+
}, M = (e, t = Date.now) => ({
|
|
97
|
+
load: async () => (await e.loadQueue()).map((e) => e.payload).filter(j),
|
|
98
|
+
save: async (n) => {
|
|
99
|
+
let r = n.map((e) => ({
|
|
100
|
+
id: e.id,
|
|
101
|
+
payload: e,
|
|
102
|
+
enqueuedAt: t(),
|
|
103
|
+
attempts: e.attempts
|
|
104
|
+
}));
|
|
105
|
+
await e.saveQueue(r);
|
|
106
|
+
}
|
|
107
|
+
}), N = () => {
|
|
108
|
+
let e = /* @__PURE__ */ new Map();
|
|
109
|
+
return {
|
|
110
|
+
kind: "in-memory",
|
|
111
|
+
get: (t) => Promise.resolve(e.get(t)),
|
|
112
|
+
put: (t, n) => (e.set(t, n), Promise.resolve()),
|
|
113
|
+
del: (t) => (e.delete(t), Promise.resolve()),
|
|
114
|
+
keysWithPrefix: (t) => Promise.resolve([...e.keys()].filter((e) => e.startsWith(t)))
|
|
115
|
+
};
|
|
116
|
+
}, P = (e) => new Promise((t, n) => {
|
|
117
|
+
e.onsuccess = () => t(e.result), e.onerror = () => n(e.error ?? /* @__PURE__ */ Error("IndexedDB request failed"));
|
|
118
|
+
}), F = "kv", I = async (e) => {
|
|
119
|
+
let t = e?.factory ?? globalThis.indexedDB;
|
|
120
|
+
if (t === void 0) throw Error("createIndexedDbKv: no IndexedDB available. Pass options.factory or use createInMemoryKv().");
|
|
121
|
+
let n = t.open(e?.databaseName ?? "voltro-local-first-kv", 1), r = await new Promise((e, t) => {
|
|
122
|
+
n.onupgradeneeded = () => {
|
|
123
|
+
n.result.objectStoreNames.contains(F) || n.result.createObjectStore(F);
|
|
124
|
+
}, n.onsuccess = () => e(n.result), n.onerror = () => t(n.error ?? /* @__PURE__ */ Error("IndexedDB open failed"));
|
|
125
|
+
}), i = (e) => r.transaction(F, e).objectStore(F);
|
|
126
|
+
return {
|
|
127
|
+
kind: "indexeddb",
|
|
128
|
+
get: (e) => P(i("readonly").get(e)),
|
|
129
|
+
put: async (e, t) => {
|
|
130
|
+
await P(i("readwrite").put(t, e));
|
|
131
|
+
},
|
|
132
|
+
del: async (e) => {
|
|
133
|
+
await P(i("readwrite").delete(e));
|
|
134
|
+
},
|
|
135
|
+
keysWithPrefix: async (e) => {
|
|
136
|
+
let t = globalThis.IDBKeyRange;
|
|
137
|
+
return t === void 0 ? (await P(i("readonly").getAllKeys())).filter((t) => t.startsWith(e)) : await P(i("readonly").getAllKeys(t.bound(e, `${e}\uffff`)));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}, L = async (e) => {
|
|
141
|
+
try {
|
|
142
|
+
return {
|
|
143
|
+
kv: await I(e),
|
|
144
|
+
durability: "durable"
|
|
145
|
+
};
|
|
146
|
+
} catch {
|
|
147
|
+
return {
|
|
148
|
+
kv: N(),
|
|
149
|
+
durability: "memory"
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}, R = "mirror", z = "\0", B = (e) => `${R}${z}${e.subjectId}${z}${e.tenantId}${z}`, V = (e, t) => `${B(e)}q${z}${t}`, H = (e, t) => {
|
|
153
|
+
if (t.length === 0) return e;
|
|
154
|
+
let n = (e) => {
|
|
155
|
+
if (typeof e != "object" || !e || Array.isArray(e)) return e;
|
|
156
|
+
let n = {};
|
|
157
|
+
for (let [r, i] of Object.entries(e)) t.includes(r) || (n[r] = i);
|
|
158
|
+
return n;
|
|
159
|
+
};
|
|
160
|
+
return Array.isArray(e) ? e.map(n) : n(e);
|
|
161
|
+
}, U = (e) => {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.stringify(e)?.length ?? 0;
|
|
164
|
+
} catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
}, W = (e, t, n = {}, r = Date.now) => ({
|
|
168
|
+
partition: t,
|
|
169
|
+
save: async (i, a) => {
|
|
170
|
+
let o = n.encryptedColumnsOf?.(a.tag) ?? [], s = a.fingerprint, c = {
|
|
171
|
+
tag: a.tag,
|
|
172
|
+
data: H(a.data, o),
|
|
173
|
+
revision: a.revision,
|
|
174
|
+
savedAt: r(),
|
|
175
|
+
...s === void 0 ? {} : { fingerprint: s }
|
|
176
|
+
};
|
|
177
|
+
await e.put(V(t, i), c);
|
|
178
|
+
},
|
|
179
|
+
load: async (n) => {
|
|
180
|
+
let r = await e.get(V(t, n));
|
|
181
|
+
if (typeof r != "object" || !r) return;
|
|
182
|
+
let i = r;
|
|
183
|
+
return typeof i.revision == "number" && typeof i.tag == "string" ? i : void 0;
|
|
184
|
+
},
|
|
185
|
+
purge: async () => {
|
|
186
|
+
let n = await e.keysWithPrefix(B(t));
|
|
187
|
+
for (let t of n) await e.del(t);
|
|
188
|
+
},
|
|
189
|
+
enforceBudget: async (n) => {
|
|
190
|
+
let r = await e.keysWithPrefix(B(t)), i = [];
|
|
191
|
+
for (let t of r) {
|
|
192
|
+
let n = await e.get(t);
|
|
193
|
+
i.push({
|
|
194
|
+
key: t,
|
|
195
|
+
savedAt: n?.savedAt ?? 0,
|
|
196
|
+
bytes: U(n)
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
let a = i.reduce((e, t) => e + t.bytes, 0), o = [];
|
|
200
|
+
i.sort((e, t) => e.savedAt - t.savedAt);
|
|
201
|
+
for (let t of i) {
|
|
202
|
+
if (a <= n) break;
|
|
203
|
+
await e.del(t.key), a -= t.bytes, o.push(t.key);
|
|
204
|
+
}
|
|
205
|
+
return o;
|
|
206
|
+
}
|
|
207
|
+
}), G = (e, t) => {
|
|
208
|
+
let n = (e) => {
|
|
209
|
+
let n = t.tags[e];
|
|
210
|
+
if (n !== void 0) return t.metadata?.[n]?.encryptedColumns;
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
load: async (n) => {
|
|
214
|
+
let r = await e.load(n);
|
|
215
|
+
if (r === void 0 || t.tags[r.tag] === void 0) return;
|
|
216
|
+
let i = r.fingerprint;
|
|
217
|
+
if (t.schemaFingerprint === void 0 || i === t.schemaFingerprint) return {
|
|
218
|
+
data: r.data,
|
|
219
|
+
revision: r.revision
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
save: async (r, i) => {
|
|
223
|
+
if (i.tag === void 0 || t.tags[i.tag] === void 0) return;
|
|
224
|
+
let a = n(i.tag) ?? [], o = a.length === 0 ? i.data : K(i.data, a);
|
|
225
|
+
await e.save(r, {
|
|
226
|
+
tag: i.tag,
|
|
227
|
+
data: o,
|
|
228
|
+
revision: i.revision,
|
|
229
|
+
...t.schemaFingerprint === void 0 ? {} : { fingerprint: t.schemaFingerprint }
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}, K = (e, t) => {
|
|
234
|
+
let n = (e) => {
|
|
235
|
+
if (typeof e != "object" || !e || Array.isArray(e)) return e;
|
|
236
|
+
let n = {};
|
|
237
|
+
for (let [r, i] of Object.entries(e)) t.includes(r) || (n[r] = i);
|
|
238
|
+
return n;
|
|
239
|
+
};
|
|
240
|
+
return Array.isArray(e) ? e.map(n) : n(e);
|
|
241
|
+
}, q = (e, t, n, r = {}) => {
|
|
242
|
+
let i = new Set(r.crdtColumns ?? []), o = r.backend ?? d, s = e.resolveRecord(Object.fromEntries(Object.entries(t).filter(([e]) => !i.has(e))), Object.fromEntries(Object.entries(n).filter(([e]) => !i.has(e))));
|
|
243
|
+
for (let e of i) {
|
|
244
|
+
let r = t[e]?.value, i = n[e]?.value;
|
|
245
|
+
r !== void 0 && i !== void 0 ? s[e] = a(r, i, o) : r === void 0 ? i !== void 0 && (s[e] = i) : s[e] = r;
|
|
246
|
+
}
|
|
247
|
+
return s;
|
|
248
|
+
}, J = async (e, t) => {
|
|
249
|
+
let n = e.locks === null ? void 0 : e.locks ?? globalThis.navigator?.locks;
|
|
250
|
+
if (n === void 0) return await t(), "no-locks";
|
|
251
|
+
let r = "held-elsewhere";
|
|
252
|
+
return await n.request(`voltro:outbox:${e.name}`, {
|
|
253
|
+
mode: "exclusive",
|
|
254
|
+
ifAvailable: !0
|
|
255
|
+
}, async (e) => {
|
|
256
|
+
e !== null && (await t(), r = "drained");
|
|
257
|
+
}), r;
|
|
258
|
+
}, Y = () => ({ rooms: /* @__PURE__ */ new Map() }), X = (e = Y()) => {
|
|
94
259
|
let t = /* @__PURE__ */ new Set(), n = (t) => {
|
|
95
260
|
let n = e.rooms.get(t);
|
|
96
261
|
return n === void 0 && (n = /* @__PURE__ */ new Set(), e.rooms.set(t, n)), n;
|
|
@@ -112,16 +277,12 @@ var h = (e) => {
|
|
|
112
277
|
};
|
|
113
278
|
}
|
|
114
279
|
};
|
|
115
|
-
},
|
|
280
|
+
}, Z = [
|
|
116
281
|
"durable-persistence-indexeddb",
|
|
117
282
|
"sync-transport-syncclient",
|
|
118
283
|
"sync-transport-react-binding",
|
|
119
284
|
"presence-room",
|
|
120
285
|
"localFirst-mixin-discovery"
|
|
121
|
-
],
|
|
122
|
-
"sync-transport-app-tags",
|
|
123
|
-
"presence-broker-binding",
|
|
124
|
-
"wasm-sqlite-durable-adapter"
|
|
125
|
-
];
|
|
286
|
+
], Q = ["sync-transport-app-tags"];
|
|
126
287
|
//#endregion
|
|
127
|
-
export {
|
|
288
|
+
export { Z as IMPLEMENTED_SINCE_SEAMS, Q as RUNTIME_SEAMS, u as canDrain, y as conflictPolicy, f as connectionReducer, h as crdtMergeCell, n as crdtText, L as createDurableKv, N as createInMemoryKv, x as createInMemoryPersistence, Y as createInMemoryPresenceBus, X as createInMemoryPresenceChannel, I as createIndexedDbKv, O as createIndexedDbPersistence, m as createPresenceRoom, W as createQueryMirror, G as createSubscriptionMirrorBinding, l as createSyncClient, r as decodeCrdtText, d as defaultCrdtBackend, o as deriveSyncStatus, c as emptyCrdtState, e as initialConnection, p as initialSyncQueue, _ as lastWriteWins, A as loadPersistedSyncQueue, a as mergeCrdtStates, M as outboxPersistence, t as outstandingCount, q as resolveWithPolicy, s as syncQueueReducer, J as withDrainLock, i as yjsBackend };
|
package/dist/react.d.ts
CHANGED
|
@@ -6,18 +6,29 @@ declare interface ConnectionState {
|
|
|
6
6
|
readonly attempt: number;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
* A CRDT implementation. One per library. The default is {@link yjsBackend}.
|
|
11
|
-
*
|
|
12
|
-
* `merge` and `decodeText` are STATELESS with respect to any live handle: they
|
|
13
|
-
* operate purely on encoded bytes, which is what lets the sync layer and tests
|
|
14
|
-
* converge two states without holding a document open.
|
|
15
|
-
*/
|
|
16
9
|
declare interface CrdtBackend {
|
|
17
10
|
/** Identifies the library, e.g. `"yjs"`. Surfaced for diagnostics/telemetry. */
|
|
18
11
|
readonly name: string;
|
|
19
12
|
/** Create a fresh text buffer, optionally seeded with `initial`. */
|
|
20
13
|
createText: (initial?: string) => CrdtTextHandle;
|
|
14
|
+
/** Create a whole-document handle, optionally seeded from encoded state. */
|
|
15
|
+
createDoc: (initial?: CrdtState) => CrdtDocHandle;
|
|
16
|
+
/**
|
|
17
|
+
* Re-encode a state through a live document so the backend garbage-collects
|
|
18
|
+
* what it can (Yjs: `mergeUpdates` never GCs tombstones, so a hot document's
|
|
19
|
+
* blob grows monotonically without this). Loses UPDATE HISTORY by design —
|
|
20
|
+
* a version-history feature must snapshot BEFORE compacting.
|
|
21
|
+
*/
|
|
22
|
+
compact: (state: CrdtState) => CrdtState;
|
|
23
|
+
/**
|
|
24
|
+
* HARD compaction for a text state: re-create the document from its
|
|
25
|
+
* MATERIALISED text, dropping all history, tombstones and client ids. The
|
|
26
|
+
* result is minimal — and a NEW EPOCH: it does not merge with updates from
|
|
27
|
+
* the old lineage, so the server treats a rebase as a fresh snapshot for
|
|
28
|
+
* every subscriber (which the subscription lane already handles as a
|
|
29
|
+
* reset). Use when `compact` no longer bounds a hot document.
|
|
30
|
+
*/
|
|
31
|
+
rebaseText: (state: CrdtState) => CrdtState;
|
|
21
32
|
/**
|
|
22
33
|
* Merge two encoded states into one converged encoded state.
|
|
23
34
|
*
|
|
@@ -33,6 +44,70 @@ declare interface CrdtBackend {
|
|
|
33
44
|
emptyState: () => CrdtState;
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A CRDT implementation. One per library. The default is {@link yjsBackend}.
|
|
49
|
+
*
|
|
50
|
+
* `merge` and `decodeText` are STATELESS with respect to any live handle: they
|
|
51
|
+
* operate purely on encoded bytes, which is what lets the sync layer and tests
|
|
52
|
+
* converge two states without holding a document open.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* A live, mutable CRDT DOCUMENT — the whole-doc generalisation `crdtDoc()`
|
|
56
|
+
* stores (rich text, maps, arrays; whatever the backend's document model
|
|
57
|
+
* holds). `crdtText()` remains the single-field specialisation over the same
|
|
58
|
+
* backend.
|
|
59
|
+
*/
|
|
60
|
+
declare interface CrdtDocHandle {
|
|
61
|
+
/**
|
|
62
|
+
* The underlying library document (a `Y.Doc` for the yjs backend), typed
|
|
63
|
+
* opaquely: an EDITOR BINDING needs the real object (Tiptap's Collaboration
|
|
64
|
+
* extension takes a Y.Doc), while every sync-layer consumer only moves
|
|
65
|
+
* encoded bytes and must never reach in. Cast at the binding, nowhere else.
|
|
66
|
+
*/
|
|
67
|
+
readonly raw: unknown;
|
|
68
|
+
/** Full state, encoded for transport or merge. */
|
|
69
|
+
encodeState: () => CrdtState;
|
|
70
|
+
/** This doc's state VECTOR — the compact "what I have" summary a peer
|
|
71
|
+
* diffs against. */
|
|
72
|
+
stateVector: () => Uint8Array;
|
|
73
|
+
/**
|
|
74
|
+
* Encode only what a peer holding `sinceVector` is missing — the
|
|
75
|
+
* INCREMENTAL update lane. A 1-character edit against a 100 KB doc encodes
|
|
76
|
+
* to a few dozen bytes, not the full state.
|
|
77
|
+
*/
|
|
78
|
+
encodeUpdateSince: (sinceVector: Uint8Array) => CrdtState;
|
|
79
|
+
/** Fold an encoded state or incremental update into this doc, in place. */
|
|
80
|
+
applyState: (state: CrdtState) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Subscribe to update blobs this doc produces. Returns the unsubscribe.
|
|
83
|
+
*
|
|
84
|
+
* `local` distinguishes an edit made HERE from one produced by folding a
|
|
85
|
+
* remote state through `applyState`, and it is the difference between a
|
|
86
|
+
* working push loop and an echo: without it, applying a peer's update fires
|
|
87
|
+
* this handler, the app pushes it back, and every client re-broadcasts what
|
|
88
|
+
* it just received. Measured with three tabs open: one keystroke produced
|
|
89
|
+
* three server writes instead of one. It converges — the merge is
|
|
90
|
+
* idempotent — but the amplification scales with the session.
|
|
91
|
+
*
|
|
92
|
+
* It had to come from the BACKEND rather than an `applying` flag around
|
|
93
|
+
* `applyState` in app code: that flag is only correct while the backend emits
|
|
94
|
+
* synchronously, which `CrdtBackend` deliberately does not promise ("the
|
|
95
|
+
* backend decision lives behind our abstraction so it can change").
|
|
96
|
+
*/
|
|
97
|
+
onUpdate: (handler: (update: CrdtState, meta: {
|
|
98
|
+
readonly local: boolean;
|
|
99
|
+
}) => void) => () => void;
|
|
100
|
+
/**
|
|
101
|
+
* Encode a stable ANCHOR at `index` of the named text field — a position
|
|
102
|
+
* that survives concurrent edits (the primitive an inline-comment UI pins
|
|
103
|
+
* threads with; the UI itself is the comments plugin's business).
|
|
104
|
+
*/
|
|
105
|
+
encodeAnchor: (field: string, index: number) => Uint8Array;
|
|
106
|
+
/** Resolve an anchor back to its current index, or `undefined` when the
|
|
107
|
+
* anchored region was deleted. */
|
|
108
|
+
resolveAnchor: (encoded: Uint8Array) => number | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
36
111
|
/** Identifies one CRDT-managed cell: a `crdtText()` COLUMN of one ROW of one TABLE. */
|
|
37
112
|
declare interface CrdtDocKey {
|
|
38
113
|
readonly table: string;
|
|
@@ -41,9 +116,9 @@ declare interface CrdtDocKey {
|
|
|
41
116
|
}
|
|
42
117
|
|
|
43
118
|
/**
|
|
44
|
-
* One local edit, as
|
|
45
|
-
*
|
|
46
|
-
*
|
|
119
|
+
* One local edit, as a hook's `push` option receives it: the cell it belongs
|
|
120
|
+
* to, the encoded update to write, and how many times delivering it has already
|
|
121
|
+
* failed.
|
|
47
122
|
*
|
|
48
123
|
* `attempts` is carried because the sync client deliberately has no built-in
|
|
49
124
|
* backoff delay — spacing or jittering a retry is the transport's lever, and it
|
|
@@ -199,6 +274,95 @@ export declare interface UseConnectionStatus {
|
|
|
199
274
|
*/
|
|
200
275
|
export declare const useConnectionStatus: (outstanding?: number) => UseConnectionStatus;
|
|
201
276
|
|
|
277
|
+
export declare interface UseCrdtDoc {
|
|
278
|
+
/**
|
|
279
|
+
* The live document — pass it straight to `useCrdtEditor({ doc })`.
|
|
280
|
+
*
|
|
281
|
+
* `null` until the mount effect has run. That is not a papercut to design
|
|
282
|
+
* away: constructing it during render is what made the editor crash a
|
|
283
|
+
* prerender and leak an instance under StrictMode. Render the editor in a
|
|
284
|
+
* child component so its `useCrdtEditor` call is not conditional:
|
|
285
|
+
* `{doc.doc !== null ? <Editor doc={doc.doc} /> : null}`.
|
|
286
|
+
*
|
|
287
|
+
* Its encoded state is one call away (`doc.encodeState()`), which is why
|
|
288
|
+
* this hook exposes no separate `state` field — there would be two answers
|
|
289
|
+
* to one question.
|
|
290
|
+
*/
|
|
291
|
+
readonly doc: CrdtDocHandle | null;
|
|
292
|
+
/**
|
|
293
|
+
* `true` once an authoritative state has been folded in. Distinguishes an
|
|
294
|
+
* EMPTY document from one that has not arrived yet — the same distinction
|
|
295
|
+
* `remote: null` makes on the way in, surfaced so a UI can show a skeleton
|
|
296
|
+
* instead of an empty editor a user might start typing into.
|
|
297
|
+
*/
|
|
298
|
+
readonly loaded: boolean;
|
|
299
|
+
/** Writes not yet acknowledged. `0` means every local edit has landed. */
|
|
300
|
+
readonly outstanding: number;
|
|
301
|
+
/** `outstanding === 0`. */
|
|
302
|
+
readonly synced: boolean;
|
|
303
|
+
/** Report reachability. Going online drains the queue. */
|
|
304
|
+
readonly setOnline: (online: boolean) => void;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A collaborative rich-text document bound to one `crdtDoc()` cell.
|
|
309
|
+
*
|
|
310
|
+
* ```tsx
|
|
311
|
+
* const row = useSubscription('app', 'documents.byId', { id })
|
|
312
|
+
* const save = useMutation('app', 'documents.setBody')
|
|
313
|
+
* const shared = useCrdtDoc({
|
|
314
|
+
* cell: { table: 'documents', id, column: 'body' },
|
|
315
|
+
* remote: row.data?.body ?? null,
|
|
316
|
+
* push: (w) => save.mutate({ id: w.id, update: w.update }),
|
|
317
|
+
* })
|
|
318
|
+
*
|
|
319
|
+
* // In a CHILD component, so `useCrdtEditor` is never called conditionally:
|
|
320
|
+
* const editor = useCrdtEditor({ doc })
|
|
321
|
+
* return <EditorContent editor={editor} />
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
export declare const useCrdtDoc: (options: UseCrdtDocOptions) => UseCrdtDoc;
|
|
325
|
+
|
|
326
|
+
export declare interface UseCrdtDocOptions {
|
|
327
|
+
/** The cell: table + row id + the `crdtDoc()` column. */
|
|
328
|
+
readonly cell: CrdtDocKey;
|
|
329
|
+
/**
|
|
330
|
+
* The authoritative state as the app currently sees it — the column's value
|
|
331
|
+
* from the reactive query streaming this row.
|
|
332
|
+
*
|
|
333
|
+
* `null`/`undefined` means "not loaded yet" and is folded as nothing, NOT as
|
|
334
|
+
* an empty document: treating a loading row as empty would let the first
|
|
335
|
+
* keystroke race the load and push a state that erases the stored document.
|
|
336
|
+
*/
|
|
337
|
+
readonly remote?: CrdtState | null | undefined;
|
|
338
|
+
/**
|
|
339
|
+
* Deliver a local edit to the server. Bind it to the mutation that writes the
|
|
340
|
+
* `crdtDoc()` column; the server folds it into the stored state
|
|
341
|
+
* authoritatively, so this never has to read-modify-write.
|
|
342
|
+
*
|
|
343
|
+
* What it receives is an INCREMENTAL Yjs update, not the full document — a
|
|
344
|
+
* one-character edit in a 100 KB document is a few dozen bytes.
|
|
345
|
+
*
|
|
346
|
+
* REJECT to trigger the queue's retry — a resolved promise is an ack.
|
|
347
|
+
*/
|
|
348
|
+
readonly push: (write: CrdtPush) => Promise<unknown>;
|
|
349
|
+
/**
|
|
350
|
+
* Durable local storage. Omit for an ephemeral (in-memory) client.
|
|
351
|
+
*
|
|
352
|
+
* Must be a STABLE reference — it is a construction option, so a new object
|
|
353
|
+
* each render rebuilds the sync client AND the document, dropping the queue.
|
|
354
|
+
* Build it once (`await createIndexedDbPersistence(…)`) and hold it in state
|
|
355
|
+
* or a module constant. Same for `backend`, `online` and `maxAttempts`.
|
|
356
|
+
*/
|
|
357
|
+
readonly adapter?: PersistenceAdapter;
|
|
358
|
+
/** CRDT backend for the local optimistic merge. Defaults to Yjs. */
|
|
359
|
+
readonly backend?: CrdtBackend;
|
|
360
|
+
/** Seed the connection belief (default `true`). */
|
|
361
|
+
readonly online?: boolean;
|
|
362
|
+
/** Stop re-driving a write after this many failed attempts. Default 5. */
|
|
363
|
+
readonly maxAttempts?: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
202
366
|
export declare interface UseCrdtText {
|
|
203
367
|
/** The merged text — local edits and every peer's, converged. */
|
|
204
368
|
readonly text: string;
|