@voltro/local-first 0.32.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/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. REMAINING (runtime binding, NOT this package): supply
307
- * the two transport functions from a running app — `push` from the client's
308
- * mutation caller (`useMutation` / `resolveByTag` writing the `crdtText()`
309
- * column), `onRemoteState` from the reactive subscription that streams the row.
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 thin app-level
482
- * binding to it, not un-built framework code:
483
- * - a `SyncTransport` bound to a specific running app's mutation/subscription
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-binding", "presence-broker-binding", "wasm-sqlite-durable-adapter"];
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, i as n, n as r, o as i, r as a, s as o, t as s } from "./room-CzWRYppA.js";
2
- import * as c from "yjs";
3
- //#region src/crdt/yjsBackend.ts
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
- }, b = (e, t) => t.updatedAt > e.updatedAt ? t.value : t.updatedAt < e.updatedAt ? e.value : y(t) > y(e) ? t.value : e.value, x = (e) => e === "lastWriteWins" ? b : e, S = (e) => {
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 ? b : x(i))(n, r);
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
- }, C = (e, t) => `${e}${t}`, w = () => {
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(C(t, n));
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(C(t, n), r(i)), Promise.resolve()),
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(C(e, t)), Promise.resolve()),
83
- isPinned: (e, t) => Promise.resolve(n.has(C(e, t)))
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
- }, T = "voltro_local_first", E = "\0queue", D = "\0pin\0", O = (e, t) => `cell\u0000${e}\u0000${t}`, k = (e, t) => `${D}${e}\u0000${t}`, A = (e) => new Promise((t, n) => {
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
- }), j = async (e) => {
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(T) || e.createObjectStore(T);
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) => A(i.transaction(T, "readonly").objectStore(T).get(e)), o = async (e, t) => {
96
- await A(i.transaction(T, "readwrite").objectStore(T).put(t, e));
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(O(e, t))),
67
+ loadCrdtState: async (e, t) => s(await a(w(e, t))),
105
68
  saveCrdtState: async (e, t, n) => {
106
- await o(O(e, t), n.slice());
69
+ await o(w(e, t), n.slice());
107
70
  },
108
71
  loadQueue: async () => {
109
- let e = await a(E);
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(E, t);
77
+ await o(S, t);
115
78
  },
116
79
  pin: async (e, t) => {
117
- await o(k(e, t), !0);
80
+ await o(T(e, t), !0);
118
81
  },
119
- isPinned: async (e, t) => await a(k(e, t)) === !0
82
+ isPinned: async (e, t) => await a(T(e, t)) === !0
120
83
  };
121
- }, M = (e) => e.inFlight === void 0 ? e.pending : [e.inFlight, ...e.pending], N = async (e, n = !0) => {
122
- let r = await e.loadQueue(), a = {
123
- ...i(n),
124
- pending: r
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: () => a,
128
- dispatch: async (n) => (a = t(a, n), await e.saveQueue(M(a)), a)
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
- }, R = () => ({ rooms: /* @__PURE__ */ new Map() }), z = (e = R()) => {
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
- }, B = [
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
- ], V = [
238
- "sync-transport-app-binding",
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 { B as IMPLEMENTED_SINCE_SEAMS, V as RUNTIME_SEAMS, e as canDrain, S as conflictPolicy, r as connectionReducer, h as crdtText, w as createInMemoryPersistence, R as createInMemoryPresenceBus, z as createInMemoryPresenceChannel, j as createIndexedDbPersistence, s as createPresenceRoom, L as createSyncClient, _ as decodeCrdtText, m as defaultCrdtBackend, a as deriveSyncStatus, v as emptyCrdtState, n as initialConnection, i as initialSyncQueue, b as lastWriteWins, N as loadPersistedSyncQueue, g as mergeCrdtStates, o as outstandingCount, t as syncQueueReducer, p as yjsBackend };
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;