@voltro/local-first 0.33.0 → 0.34.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 +1801 -0
- package/dist/index.d.ts +20 -9
- package/dist/index.js +33 -149
- package/dist/react.d.ts +199 -0
- package/dist/react.js +162 -54
- package/dist/room-wFNUwEgi.js +251 -0
- package/package.json +1 -1
- package/dist/room-CzWRYppA.js +0 -134
package/dist/index.d.ts
CHANGED
|
@@ -303,10 +303,20 @@ declare interface IDBTransactionLike {
|
|
|
303
303
|
* a `SyncTransport`: a local edit → optimistic merge + queued write; reconnect
|
|
304
304
|
* → drain to `push`; incoming merged state → local CRDT merge; convergence.
|
|
305
305
|
* Tested against an in-memory dispatcher that mirrors the server's
|
|
306
|
-
* authoritative merge.
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
306
|
+
* authoritative merge.
|
|
307
|
+
*
|
|
308
|
+
* DONE — The React binding over that wire.
|
|
309
|
+
* `useCrdtText` (./react/useCrdtText) owns the client's LIFECYCLE for one
|
|
310
|
+
* `crdtText()` cell: one client per (table,id,column) created and closed with
|
|
311
|
+
* the component, re-render on local edit / ack / incoming state, the streamed
|
|
312
|
+
* row folded back in, and — the part every consumer would otherwise hand-roll
|
|
313
|
+
* wrong — `setText` as a prefix/suffix span diff rather than a
|
|
314
|
+
* delete-all/insert-all, which is what silently turns a CRDT column back into
|
|
315
|
+
* last-write-wins. What is left of this seam is NOT lifecycle code: it is the
|
|
316
|
+
* two app-specific names (`push` → the mutation that writes the column,
|
|
317
|
+
* `remote` → the reactive query that streams the row). There is no generated
|
|
318
|
+
* per-table CRUD surface to derive those from, so the app supplies them — the
|
|
319
|
+
* same shape as `usePresence`'s injected `channel`.
|
|
310
320
|
*
|
|
311
321
|
* DONE — Presence / awareness.
|
|
312
322
|
* `createPresenceRoom` (./presence/room) + the `usePresence` hook implement
|
|
@@ -325,7 +335,7 @@ declare interface IDBTransactionLike {
|
|
|
325
335
|
* like any column type). `crdtText()` was already a real column type with an
|
|
326
336
|
* authoritative server merge on the write path.
|
|
327
337
|
*/
|
|
328
|
-
export declare const IMPLEMENTED_SINCE_SEAMS: readonly ["durable-persistence-indexeddb", "sync-transport-syncclient", "presence-room", "localFirst-mixin-discovery"];
|
|
338
|
+
export declare const IMPLEMENTED_SINCE_SEAMS: readonly ["durable-persistence-indexeddb", "sync-transport-syncclient", "sync-transport-react-binding", "presence-room", "localFirst-mixin-discovery"];
|
|
329
339
|
|
|
330
340
|
export declare type ImplementedSeam = (typeof IMPLEMENTED_SINCE_SEAMS)[number];
|
|
331
341
|
|
|
@@ -478,13 +488,14 @@ export declare interface RemoteCrdtState extends CrdtDocKey {
|
|
|
478
488
|
}
|
|
479
489
|
|
|
480
490
|
/**
|
|
481
|
-
* The genuinely REMAINING runtime seams — provisioned infra + the
|
|
482
|
-
*
|
|
483
|
-
* -
|
|
491
|
+
* The genuinely REMAINING runtime seams — provisioned infra + the two app-level
|
|
492
|
+
* NAMES nothing can derive, not un-built framework code:
|
|
493
|
+
* - the mutation tag / query tag `useCrdtText` is pointed at (the lifecycle
|
|
494
|
+
* around them ships; see the DONE entry above)
|
|
484
495
|
* - a `PresenceChannel` bound to a provisioned Redis/NATS broker at scale
|
|
485
496
|
* - (optional) a wa-sqlite/Turso durable adapter for cross-tab SQL
|
|
486
497
|
*/
|
|
487
|
-
export declare const RUNTIME_SEAMS: readonly ["sync-transport-app-
|
|
498
|
+
export declare const RUNTIME_SEAMS: readonly ["sync-transport-app-tags", "presence-broker-binding", "wasm-sqlite-durable-adapter"];
|
|
488
499
|
|
|
489
500
|
export declare type RuntimeSeam = (typeof RUNTIME_SEAMS)[number];
|
|
490
501
|
|
package/dist/index.js
CHANGED
|
@@ -1,53 +1,16 @@
|
|
|
1
|
-
import { a as e, c as t,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
var l = "t", u = (e) => c.encodeStateAsUpdate(e), d = (e) => {
|
|
5
|
-
let t = new c.Doc();
|
|
6
|
-
return e.length > 0 && c.applyUpdate(t, e), t;
|
|
7
|
-
}, f = (e) => {
|
|
8
|
-
let t = e.getText(l);
|
|
9
|
-
return {
|
|
10
|
-
toString: () => t.toString(),
|
|
11
|
-
insert: (e, n) => t.insert(e, n),
|
|
12
|
-
delete: (e, n) => t.delete(e, n),
|
|
13
|
-
encodeState: () => u(e),
|
|
14
|
-
applyState: (t) => {
|
|
15
|
-
t.length > 0 && c.applyUpdate(e, t);
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
}, p = {
|
|
19
|
-
name: "yjs",
|
|
20
|
-
createText: (e) => {
|
|
21
|
-
let t = new c.Doc();
|
|
22
|
-
return e !== void 0 && e.length > 0 && t.getText(l).insert(0, e), f(t);
|
|
23
|
-
},
|
|
24
|
-
merge: (e, t) => c.mergeUpdates([e, t]),
|
|
25
|
-
decodeText: (e) => {
|
|
26
|
-
let t = d(e), n = t.getText(l).toString();
|
|
27
|
-
return t.destroy(), n;
|
|
28
|
-
},
|
|
29
|
-
emptyState: () => u(new c.Doc())
|
|
30
|
-
}, m = p, h = (e, t = m) => {
|
|
31
|
-
let n = t.createText(e), r = {
|
|
32
|
-
backend: t.name,
|
|
33
|
-
toString: () => n.toString(),
|
|
34
|
-
insert: (e, t) => (n.insert(e, t), r),
|
|
35
|
-
delete: (e, t) => (n.delete(e, t), r),
|
|
36
|
-
merge: (e) => (n.applyState(e), r),
|
|
37
|
-
encode: () => n.encodeState()
|
|
38
|
-
};
|
|
39
|
-
return r;
|
|
40
|
-
}, g = (e, t, n = m) => n.merge(e, t), _ = (e, t = m) => t.decodeText(e), v = (e = m) => e.emptyState(), y = (e) => {
|
|
1
|
+
import { a as e, c as t, d as n, f as r, h as i, i as a, l as o, m as s, n as c, o as l, p as u, r as d, s as f, t as p, u as m } from "./room-wFNUwEgi.js";
|
|
2
|
+
//#region src/conflict/policy.ts
|
|
3
|
+
var h = (e) => {
|
|
41
4
|
if (e.writer !== void 0) return `w:${e.writer}`;
|
|
42
5
|
try {
|
|
43
6
|
return `v:${JSON.stringify(e.value)}`;
|
|
44
7
|
} catch {
|
|
45
8
|
return `v:${String(e.value)}`;
|
|
46
9
|
}
|
|
47
|
-
},
|
|
10
|
+
}, g = (e, t) => t.updatedAt > e.updatedAt ? t.value : t.updatedAt < e.updatedAt ? e.value : h(t) > h(e) ? t.value : e.value, _ = (e) => e === "lastWriteWins" ? g : e, v = (e) => {
|
|
48
11
|
let t = (t, n, r) => {
|
|
49
12
|
let i = e[t];
|
|
50
|
-
return (i === void 0 ?
|
|
13
|
+
return (i === void 0 ? g : _(i))(n, r);
|
|
51
14
|
};
|
|
52
15
|
return {
|
|
53
16
|
resolveField: t,
|
|
@@ -68,32 +31,32 @@ var l = "t", u = (e) => c.encodeStateAsUpdate(e), d = (e) => {
|
|
|
68
31
|
return r;
|
|
69
32
|
}
|
|
70
33
|
};
|
|
71
|
-
},
|
|
34
|
+
}, y = (e, t) => `${e}${t}`, b = () => {
|
|
72
35
|
let e = /* @__PURE__ */ new Map(), t = [], n = /* @__PURE__ */ new Set(), r = (e) => e.slice();
|
|
73
36
|
return {
|
|
74
37
|
kind: "in-memory",
|
|
75
38
|
loadCrdtState: (t, n) => {
|
|
76
|
-
let i = e.get(
|
|
39
|
+
let i = e.get(y(t, n));
|
|
77
40
|
return Promise.resolve(i === void 0 ? void 0 : r(i));
|
|
78
41
|
},
|
|
79
|
-
saveCrdtState: (t, n, i) => (e.set(
|
|
42
|
+
saveCrdtState: (t, n, i) => (e.set(y(t, n), r(i)), Promise.resolve()),
|
|
80
43
|
loadQueue: () => Promise.resolve(t.map((e) => ({ ...e }))),
|
|
81
44
|
saveQueue: (e) => (t = e.map((e) => ({ ...e })), Promise.resolve()),
|
|
82
|
-
pin: (e, t) => (n.add(
|
|
83
|
-
isPinned: (e, t) => Promise.resolve(n.has(
|
|
45
|
+
pin: (e, t) => (n.add(y(e, t)), Promise.resolve()),
|
|
46
|
+
isPinned: (e, t) => Promise.resolve(n.has(y(e, t)))
|
|
84
47
|
};
|
|
85
|
-
},
|
|
48
|
+
}, x = "voltro_local_first", S = "\0queue", C = "\0pin\0", w = (e, t) => `cell\u0000${e}\u0000${t}`, T = (e, t) => `${C}${e}\u0000${t}`, E = (e) => new Promise((t, n) => {
|
|
86
49
|
e.onsuccess = () => t(e.result), e.onerror = () => n(e.error ?? /* @__PURE__ */ Error("IndexedDB request failed"));
|
|
87
|
-
}),
|
|
50
|
+
}), D = async (e) => {
|
|
88
51
|
let t = e?.factory ?? globalThis.indexedDB;
|
|
89
52
|
if (t === void 0) throw Error("createIndexedDbPersistence: no IndexedDB available. Pass options.factory (e.g. in a non-browser host) or use createInMemoryPersistence().");
|
|
90
53
|
let n = e?.databaseName ?? "voltro-local-first", r = t.open(n, 1), i = await new Promise((e, t) => {
|
|
91
54
|
r.onupgradeneeded = () => {
|
|
92
55
|
let e = r.result;
|
|
93
|
-
e.objectStoreNames.contains(
|
|
56
|
+
e.objectStoreNames.contains(x) || e.createObjectStore(x);
|
|
94
57
|
}, r.onsuccess = () => e(r.result), r.onerror = () => t(r.error ?? /* @__PURE__ */ Error("IndexedDB open failed"));
|
|
95
|
-
}), a = async (e) =>
|
|
96
|
-
await
|
|
58
|
+
}), a = async (e) => E(i.transaction(x, "readonly").objectStore(x).get(e)), o = async (e, t) => {
|
|
59
|
+
await E(i.transaction(x, "readwrite").objectStore(x).put(t, e));
|
|
97
60
|
}, s = (e) => {
|
|
98
61
|
if (e instanceof Uint8Array) return e.slice();
|
|
99
62
|
if (e instanceof ArrayBuffer) return new Uint8Array(e.slice(0));
|
|
@@ -101,113 +64,33 @@ var l = "t", u = (e) => c.encodeStateAsUpdate(e), d = (e) => {
|
|
|
101
64
|
};
|
|
102
65
|
return {
|
|
103
66
|
kind: "indexeddb",
|
|
104
|
-
loadCrdtState: async (e, t) => s(await a(
|
|
67
|
+
loadCrdtState: async (e, t) => s(await a(w(e, t))),
|
|
105
68
|
saveCrdtState: async (e, t, n) => {
|
|
106
|
-
await o(
|
|
69
|
+
await o(w(e, t), n.slice());
|
|
107
70
|
},
|
|
108
71
|
loadQueue: async () => {
|
|
109
|
-
let e = await a(
|
|
72
|
+
let e = await a(S);
|
|
110
73
|
return e === void 0 || !Array.isArray(e.writes) ? [] : e.writes.map((e) => ({ ...e }));
|
|
111
74
|
},
|
|
112
75
|
saveQueue: async (e) => {
|
|
113
76
|
let t = { writes: e.map((e) => ({ ...e })) };
|
|
114
|
-
await o(
|
|
77
|
+
await o(S, t);
|
|
115
78
|
},
|
|
116
79
|
pin: async (e, t) => {
|
|
117
|
-
await o(
|
|
80
|
+
await o(T(e, t), !0);
|
|
118
81
|
},
|
|
119
|
-
isPinned: async (e, t) => await a(
|
|
82
|
+
isPinned: async (e, t) => await a(T(e, t)) === !0
|
|
120
83
|
};
|
|
121
|
-
},
|
|
122
|
-
let
|
|
123
|
-
...
|
|
124
|
-
pending:
|
|
84
|
+
}, O = (e) => e.inFlight === void 0 ? e.pending : [e.inFlight, ...e.pending], k = async (e, t = !0) => {
|
|
85
|
+
let n = await e.loadQueue(), r = {
|
|
86
|
+
...f(t),
|
|
87
|
+
pending: n
|
|
125
88
|
};
|
|
126
89
|
return {
|
|
127
|
-
getState: () =>
|
|
128
|
-
dispatch: async (
|
|
129
|
-
};
|
|
130
|
-
}, P = (e) => `${e.table}\u0000${e.id}\u0000${e.column}`, F = 0, I = () => {
|
|
131
|
-
let e = typeof crypto < "u" ? crypto : void 0;
|
|
132
|
-
return e && typeof e.randomUUID == "function" ? e.randomUUID() : `w_${Date.now().toString(36)}_${F += 1}`;
|
|
133
|
-
}, L = (n) => {
|
|
134
|
-
let { transport: r, adapter: a } = n, s = n.backend ?? m, c = n.now ?? Date.now, l = n.maxAttempts ?? 5, u = i(n.online ?? !0), d = /* @__PURE__ */ new Map(), f = /* @__PURE__ */ new Set(), p = () => {
|
|
135
|
-
for (let e of f) try {
|
|
136
|
-
e();
|
|
137
|
-
} catch {}
|
|
138
|
-
}, h = (e, t) => {
|
|
139
|
-
let n = P(e), r = d.get(n), i = r === void 0 ? t : g(r, t, s);
|
|
140
|
-
d.set(n, i), a?.saveCrdtState(e.table, e.id, i).catch(() => void 0);
|
|
141
|
-
}, _ = () => {
|
|
142
|
-
if (a === void 0) return;
|
|
143
|
-
let e = u.inFlight === void 0 ? u.pending : [u.inFlight, ...u.pending];
|
|
144
|
-
a.saveQueue(e).catch(() => void 0);
|
|
145
|
-
}, v = !1, y = async () => {
|
|
146
|
-
if (!v) {
|
|
147
|
-
v = !0;
|
|
148
|
-
try {
|
|
149
|
-
for (; e(u);) {
|
|
150
|
-
u = t(u, { type: "drain-start" });
|
|
151
|
-
let e = u.inFlight;
|
|
152
|
-
if (e === void 0) break;
|
|
153
|
-
try {
|
|
154
|
-
await r.push(e), u = t(u, {
|
|
155
|
-
type: "ack",
|
|
156
|
-
id: e.id
|
|
157
|
-
}), _(), p();
|
|
158
|
-
} catch (n) {
|
|
159
|
-
if (u = t(u, {
|
|
160
|
-
type: "fail",
|
|
161
|
-
id: e.id,
|
|
162
|
-
error: n instanceof Error ? n.message : String(n)
|
|
163
|
-
}), _(), p(), e.attempts + 1 >= l) break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
} finally {
|
|
167
|
-
v = !1;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}, b = r.onRemoteState((e) => {
|
|
171
|
-
h(e, e.state), p();
|
|
172
|
-
});
|
|
173
|
-
return {
|
|
174
|
-
enqueue: (e) => {
|
|
175
|
-
h(e, e.update);
|
|
176
|
-
let n = {
|
|
177
|
-
id: I(),
|
|
178
|
-
payload: e,
|
|
179
|
-
enqueuedAt: c(),
|
|
180
|
-
attempts: 0
|
|
181
|
-
};
|
|
182
|
-
u = t(u, {
|
|
183
|
-
type: "enqueue",
|
|
184
|
-
write: n
|
|
185
|
-
}), _(), p(), y();
|
|
186
|
-
},
|
|
187
|
-
setOnline: (e) => {
|
|
188
|
-
u = t(u, e ? { type: "online" } : { type: "offline" }), _(), p(), e && y();
|
|
189
|
-
},
|
|
190
|
-
queueState: () => u,
|
|
191
|
-
outstanding: () => o(u),
|
|
192
|
-
getState: (e) => {
|
|
193
|
-
let t = d.get(P(e));
|
|
194
|
-
return t === void 0 ? void 0 : t.slice();
|
|
195
|
-
},
|
|
196
|
-
getText: (e) => {
|
|
197
|
-
let t = d.get(P(e));
|
|
198
|
-
return t === void 0 ? "" : s.decodeText(t);
|
|
199
|
-
},
|
|
200
|
-
subscribe: (e) => (f.add(e), () => {
|
|
201
|
-
f.delete(e);
|
|
202
|
-
}),
|
|
203
|
-
flush: async () => {
|
|
204
|
-
await y();
|
|
205
|
-
},
|
|
206
|
-
close: () => {
|
|
207
|
-
b(), f.clear();
|
|
208
|
-
}
|
|
90
|
+
getState: () => r,
|
|
91
|
+
dispatch: async (t) => (r = o(r, t), await e.saveQueue(O(r)), r)
|
|
209
92
|
};
|
|
210
|
-
},
|
|
93
|
+
}, A = () => ({ rooms: /* @__PURE__ */ new Map() }), j = (e = A()) => {
|
|
211
94
|
let t = /* @__PURE__ */ new Set(), n = (t) => {
|
|
212
95
|
let n = e.rooms.get(t);
|
|
213
96
|
return n === void 0 && (n = /* @__PURE__ */ new Set(), e.rooms.set(t, n)), n;
|
|
@@ -229,15 +112,16 @@ var l = "t", u = (e) => c.encodeStateAsUpdate(e), d = (e) => {
|
|
|
229
112
|
};
|
|
230
113
|
}
|
|
231
114
|
};
|
|
232
|
-
},
|
|
115
|
+
}, M = [
|
|
233
116
|
"durable-persistence-indexeddb",
|
|
234
117
|
"sync-transport-syncclient",
|
|
118
|
+
"sync-transport-react-binding",
|
|
235
119
|
"presence-room",
|
|
236
120
|
"localFirst-mixin-discovery"
|
|
237
|
-
],
|
|
238
|
-
"sync-transport-app-
|
|
121
|
+
], N = [
|
|
122
|
+
"sync-transport-app-tags",
|
|
239
123
|
"presence-broker-binding",
|
|
240
124
|
"wasm-sqlite-durable-adapter"
|
|
241
125
|
];
|
|
242
126
|
//#endregion
|
|
243
|
-
export {
|
|
127
|
+
export { M as IMPLEMENTED_SINCE_SEAMS, N as RUNTIME_SEAMS, l as canDrain, v as conflictPolicy, d as connectionReducer, m as crdtText, b as createInMemoryPersistence, A as createInMemoryPresenceBus, j as createInMemoryPresenceChannel, D as createIndexedDbPersistence, p as createPresenceRoom, c as createSyncClient, n as decodeCrdtText, r as defaultCrdtBackend, a as deriveSyncStatus, u as emptyCrdtState, e as initialConnection, f as initialSyncQueue, g as lastWriteWins, k as loadPersistedSyncQueue, s as mergeCrdtStates, t as outstandingCount, o as syncQueueReducer, i as yjsBackend };
|
package/dist/react.d.ts
CHANGED
|
@@ -6,6 +6,128 @@ 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
|
+
declare interface CrdtBackend {
|
|
17
|
+
/** Identifies the library, e.g. `"yjs"`. Surfaced for diagnostics/telemetry. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** Create a fresh text buffer, optionally seeded with `initial`. */
|
|
20
|
+
createText: (initial?: string) => CrdtTextHandle;
|
|
21
|
+
/**
|
|
22
|
+
* Merge two encoded states into one converged encoded state.
|
|
23
|
+
*
|
|
24
|
+
* The core primitive. MUST be:
|
|
25
|
+
* - commutative up to convergence: `decodeText(merge(a,b)) === decodeText(merge(b,a))`
|
|
26
|
+
* - idempotent: `decodeText(merge(m,a)) === decodeText(m)` when `m` already contains `a`
|
|
27
|
+
* - an identity over the empty state: `merge(empty, a)` decodes to `a`'s text
|
|
28
|
+
*/
|
|
29
|
+
merge: (a: CrdtState, b: CrdtState) => CrdtState;
|
|
30
|
+
/** The plain-string view of an encoded state. */
|
|
31
|
+
decodeText: (state: CrdtState) => string;
|
|
32
|
+
/** The encoded state of an empty document — the merge identity element. */
|
|
33
|
+
emptyState: () => CrdtState;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Identifies one CRDT-managed cell: a `crdtText()` COLUMN of one ROW of one TABLE. */
|
|
37
|
+
declare interface CrdtDocKey {
|
|
38
|
+
readonly table: string;
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly column: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One local edit, as {@link UseCrdtTextOptions.push} receives it: the cell it
|
|
45
|
+
* belongs to, the encoded update to write, and how many times delivering it has
|
|
46
|
+
* already failed.
|
|
47
|
+
*
|
|
48
|
+
* `attempts` is carried because the sync client deliberately has no built-in
|
|
49
|
+
* backoff delay — spacing or jittering a retry is the transport's lever, and it
|
|
50
|
+
* cannot make that decision without knowing which attempt this is.
|
|
51
|
+
*/
|
|
52
|
+
export declare interface CrdtPush extends CrdtDocKey {
|
|
53
|
+
/** The encoded CRDT state to write into the column. */
|
|
54
|
+
readonly update: CrdtState;
|
|
55
|
+
/** Failed delivery attempts before this one. `0` on the first try. */
|
|
56
|
+
readonly attempts: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An encoded CRDT state or delta, as opaque bytes. Produced by
|
|
61
|
+
* {@link CrdtBackend.encodeState} / {@link CrdtTextHandle.encodeState},
|
|
62
|
+
* consumed by {@link CrdtBackend.merge} and {@link CrdtBackend.decodeText}.
|
|
63
|
+
*
|
|
64
|
+
* It is opaque ON PURPOSE: the shape is the backend's business. Yjs emits a
|
|
65
|
+
* v1 update blob; Loro would emit its own. Consumers only ever move these
|
|
66
|
+
* bytes around and hand them back.
|
|
67
|
+
*/
|
|
68
|
+
declare type CrdtState = Uint8Array;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The minimal single-span edit that turns `before` into `after`: the common
|
|
72
|
+
* prefix and the common suffix are kept, and everything between them is
|
|
73
|
+
* replaced.
|
|
74
|
+
*
|
|
75
|
+
* Pure and exported because it is the load-bearing half of `setText` — a naive
|
|
76
|
+
* "delete everything, insert the new string" makes every concurrent edit lose,
|
|
77
|
+
* which is the failure a CRDT is chosen to prevent, and it fails silently
|
|
78
|
+
* (the text is right on the peer that typed last).
|
|
79
|
+
*
|
|
80
|
+
* Returns `null` when the strings are equal.
|
|
81
|
+
*/
|
|
82
|
+
export declare const crdtTextEdit: (before: string, after: string) => {
|
|
83
|
+
readonly index: number;
|
|
84
|
+
readonly deleteLength: number;
|
|
85
|
+
readonly insert: string;
|
|
86
|
+
} | null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A live, mutable CRDT text buffer. Edits are local until you
|
|
90
|
+
* {@link CrdtTextHandle.encodeState | encode} them for transport/merge.
|
|
91
|
+
*/
|
|
92
|
+
declare interface CrdtTextHandle {
|
|
93
|
+
/** The current text as a plain string — the view a UI renders. */
|
|
94
|
+
toString: () => string;
|
|
95
|
+
/** Insert `text` at UTF-16 code-unit `index`. */
|
|
96
|
+
insert: (index: number, text: string) => void;
|
|
97
|
+
/** Delete `length` code units starting at `index`. */
|
|
98
|
+
delete: (index: number, length: number) => void;
|
|
99
|
+
/** This buffer's full state, encoded for transport or merge. */
|
|
100
|
+
encodeState: () => CrdtState;
|
|
101
|
+
/**
|
|
102
|
+
* Fold another buffer's encoded state into this one. Concurrent edits
|
|
103
|
+
* converge — this is the CRDT property, applied in place.
|
|
104
|
+
*/
|
|
105
|
+
applyState: (state: CrdtState) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Durable local storage for a local-first client. Every method is async because
|
|
110
|
+
* the real backings (WASM SQLite, Turso, IndexedDB) are — the in-memory impl
|
|
111
|
+
* resolves immediately but keeps the same shape so tests exercise the real
|
|
112
|
+
* contract, not a synchronous shortcut.
|
|
113
|
+
*/
|
|
114
|
+
declare interface PersistenceAdapter {
|
|
115
|
+
/** Identifies the backing, e.g. `"in-memory"` / `"wa-sqlite"`. Diagnostics. */
|
|
116
|
+
readonly kind: string;
|
|
117
|
+
/** The stored CRDT state for a record, or `undefined` if none is persisted. */
|
|
118
|
+
loadCrdtState: (table: string, id: string) => Promise<CrdtState | undefined>;
|
|
119
|
+
/** Persist a record's CRDT state, replacing any prior value. */
|
|
120
|
+
saveCrdtState: (table: string, id: string, state: CrdtState) => Promise<void>;
|
|
121
|
+
/** The persisted offline write queue (oldest first). Empty when none saved. */
|
|
122
|
+
loadQueue: () => Promise<readonly QueuedWrite[]>;
|
|
123
|
+
/** Persist the entire offline write queue, replacing the prior snapshot. */
|
|
124
|
+
saveQueue: (writes: readonly QueuedWrite[]) => Promise<void>;
|
|
125
|
+
/** Mark a record as never-evict (the plan's "pinned" records). */
|
|
126
|
+
pin: (table: string, id: string) => Promise<void>;
|
|
127
|
+
/** Whether a record is pinned. */
|
|
128
|
+
isPinned: (table: string, id: string) => Promise<boolean>;
|
|
129
|
+
}
|
|
130
|
+
|
|
9
131
|
/**
|
|
10
132
|
* A dumb ephemeral pub/sub channel. Publish a string payload to a room;
|
|
11
133
|
* subscribe to a room and get every payload (from other peers) back.
|
|
@@ -77,6 +199,83 @@ export declare interface UseConnectionStatus {
|
|
|
77
199
|
*/
|
|
78
200
|
export declare const useConnectionStatus: (outstanding?: number) => UseConnectionStatus;
|
|
79
201
|
|
|
202
|
+
export declare interface UseCrdtText {
|
|
203
|
+
/** The merged text — local edits and every peer's, converged. */
|
|
204
|
+
readonly text: string;
|
|
205
|
+
/** Insert at a UTF-16 code-unit index. */
|
|
206
|
+
readonly insert: (index: number, text: string) => void;
|
|
207
|
+
/** Delete `length` code units at `index`. */
|
|
208
|
+
readonly delete: (index: number, length: number) => void;
|
|
209
|
+
/**
|
|
210
|
+
* Replace the whole text — the shape a `<textarea>`'s `onChange` hands you.
|
|
211
|
+
* Diffed to the minimal single-span edit, so a concurrent edit elsewhere in
|
|
212
|
+
* the document is not erased.
|
|
213
|
+
*/
|
|
214
|
+
readonly setText: (next: string) => void;
|
|
215
|
+
/** The encoded merged state, or `undefined` before anything has been folded. */
|
|
216
|
+
readonly state: CrdtState | undefined;
|
|
217
|
+
/** Writes not yet acknowledged. `0` means every local edit has landed. */
|
|
218
|
+
readonly outstanding: number;
|
|
219
|
+
/** `outstanding === 0`. */
|
|
220
|
+
readonly synced: boolean;
|
|
221
|
+
/** Report reachability. Going online drains the queue. */
|
|
222
|
+
readonly setOnline: (online: boolean) => void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A collaborative text field bound to one `crdtText()` cell.
|
|
227
|
+
*
|
|
228
|
+
* ```tsx
|
|
229
|
+
* const row = useSubscription('app', 'documents.byId', { id })
|
|
230
|
+
* const save = useMutation('app', 'documents.setBody')
|
|
231
|
+
* const body = useCrdtText({
|
|
232
|
+
* cell: { table: 'documents', id, column: 'body' },
|
|
233
|
+
* remote: row.data?.body ?? null,
|
|
234
|
+
* push: (w) => save.mutate({ id: w.id, body: w.update }),
|
|
235
|
+
* })
|
|
236
|
+
*
|
|
237
|
+
* <textarea value={body.text} onChange={(e) => body.setText(e.target.value)} />
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
export declare const useCrdtText: (options: UseCrdtTextOptions) => UseCrdtText;
|
|
241
|
+
|
|
242
|
+
export declare interface UseCrdtTextOptions {
|
|
243
|
+
/** The cell: table + row id + the `crdtText()` column. */
|
|
244
|
+
readonly cell: CrdtDocKey;
|
|
245
|
+
/**
|
|
246
|
+
* The authoritative state as the app currently sees it — the column's value
|
|
247
|
+
* from the reactive query streaming this row.
|
|
248
|
+
*
|
|
249
|
+
* `null`/`undefined` means "not loaded yet" and is folded as nothing, NOT as
|
|
250
|
+
* an empty document: treating a loading row as empty would make the first
|
|
251
|
+
* keystroke race the load and could push a state that erases the stored text.
|
|
252
|
+
*/
|
|
253
|
+
readonly remote?: CrdtState | null | undefined;
|
|
254
|
+
/**
|
|
255
|
+
* Deliver a local edit to the server. Bind it to the mutation that writes the
|
|
256
|
+
* `crdtText()` column; the server folds it into the stored state
|
|
257
|
+
* authoritatively, so this never has to read-modify-write.
|
|
258
|
+
*
|
|
259
|
+
* REJECT to trigger the queue's retry — a resolved promise is an ack.
|
|
260
|
+
*/
|
|
261
|
+
readonly push: (write: CrdtPush) => Promise<unknown>;
|
|
262
|
+
/**
|
|
263
|
+
* Durable local storage. Omit for an ephemeral (in-memory) client.
|
|
264
|
+
*
|
|
265
|
+
* Must be a STABLE reference — it is a construction option, so a new object
|
|
266
|
+
* each render rebuilds the sync client and drops the queue. Build it once
|
|
267
|
+
* (`await createIndexedDbPersistence(…)`) and hold it in state or a module
|
|
268
|
+
* constant. Same for `backend`, `online` and `maxAttempts`.
|
|
269
|
+
*/
|
|
270
|
+
readonly adapter?: PersistenceAdapter;
|
|
271
|
+
/** CRDT backend for the local optimistic merge. Defaults to Yjs. */
|
|
272
|
+
readonly backend?: CrdtBackend;
|
|
273
|
+
/** Seed the connection belief (default `true`). */
|
|
274
|
+
readonly online?: boolean;
|
|
275
|
+
/** Stop re-driving a write after this many failed attempts. Default 5. */
|
|
276
|
+
readonly maxAttempts?: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
80
279
|
export declare interface UsePresence<TPresence> {
|
|
81
280
|
/** This peer's current presence. */
|
|
82
281
|
readonly presence: TPresence;
|