@voltro/local-first 0.53.0 → 0.55.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
@@ -113,7 +113,7 @@ export declare interface CrdtBackend {
113
113
  * holds). `crdtText()` remains the single-field specialisation over the same
114
114
  * backend.
115
115
  */
116
- declare interface CrdtDocHandle {
116
+ export declare interface CrdtDocHandle {
117
117
  /**
118
118
  * The underlying library document (a `Y.Doc` for the yjs backend), typed
119
119
  * opaquely: an EDITOR BINDING needs the real object (Tiptap's Collaboration
@@ -134,9 +134,25 @@ declare interface CrdtDocHandle {
134
134
  encodeUpdateSince: (sinceVector: Uint8Array) => CrdtState;
135
135
  /** Fold an encoded state or incremental update into this doc, in place. */
136
136
  applyState: (state: CrdtState) => void;
137
- /** Subscribe to update blobs this doc produces (local edits AND applied
138
- * remote states). Returns the unsubscribe. */
139
- onUpdate: (handler: (update: CrdtState) => void) => () => void;
137
+ /**
138
+ * Subscribe to update blobs this doc produces. Returns the unsubscribe.
139
+ *
140
+ * `local` distinguishes an edit made HERE from one produced by folding a
141
+ * remote state through `applyState`, and it is the difference between a
142
+ * working push loop and an echo: without it, applying a peer's update fires
143
+ * this handler, the app pushes it back, and every client re-broadcasts what
144
+ * it just received. Measured with three tabs open: one keystroke produced
145
+ * three server writes instead of one. It converges — the merge is
146
+ * idempotent — but the amplification scales with the session.
147
+ *
148
+ * It had to come from the BACKEND rather than an `applying` flag around
149
+ * `applyState` in app code: that flag is only correct while the backend emits
150
+ * synchronously, which `CrdtBackend` deliberately does not promise ("the
151
+ * backend decision lives behind our abstraction so it can change").
152
+ */
153
+ onUpdate: (handler: (update: CrdtState, meta: {
154
+ readonly local: boolean;
155
+ }) => void) => () => void;
140
156
  /**
141
157
  * Encode a stable ANCHOR at `index` of the named text field — a position
142
158
  * that survives concurrent edits (the primitive an inline-comment UI pins
@@ -765,14 +781,30 @@ export declare interface ResolveWithPolicyOptions {
765
781
  }
766
782
 
767
783
  /**
768
- * The genuinely REMAINING runtime seamsprovisioned infra + the two app-level
769
- * NAMES nothing can derive, not un-built framework code:
770
- * - the mutation tag / query tag `useCrdtText` is pointed at (the lifecycle
771
- * around them ships; see the DONE entry above)
772
- * - a `PresenceChannel` bound to a provisioned Redis/NATS broker at scale
773
- * - (optional) a wa-sqlite/Turso durable adapter for cross-tab SQL
784
+ * The genuinely REMAINING runtime seam — the two app-level NAMES nothing can
785
+ * derive, not un-built framework code: the mutation tag that writes the
786
+ * `crdtText()` column and the reactive query tag that streams the row, which
787
+ * `useCrdtText` is pointed at. The lifecycle around them ships; see the DONE
788
+ * entry above.
789
+ *
790
+ * **Two entries left this list, in opposite directions, and the distinction is
791
+ * the reason this comment is longer than the array.** A seam is work somebody
792
+ * still has to do. Neither of these was:
793
+ *
794
+ * - `presence-broker-binding` is BUILT (`usePresenceChannel` in
795
+ * `@voltro/plugin-presence/web`, riding the framework's own presence lane;
796
+ * shape pinned by `presence/channelParity.test-d.ts`). The DONE entry above
797
+ * has said so since plan 02 phase 1 while this array said the opposite —
798
+ * one file claiming both, which is worse than either claim alone.
799
+ * - `wasm-sqlite-durable-adapter` was REJECTED, with the reasoning recorded
800
+ * in `mirror/queryMirror.ts`: the client's query surface is `(tag, input)`
801
+ * and predicates never exist client-side, so a browser SQL engine would
802
+ * evaluate a language the client never sees. A decision we made is not a
803
+ * gap in the framework, and listing it as one invites somebody to close it.
804
+ * The `KvStore` seam still admits a SQLite BACKING (mobile's expo-sqlite
805
+ * adapter takes exactly that) — that is a different, already-open door.
774
806
  */
775
- export declare const RUNTIME_SEAMS: readonly ["sync-transport-app-tags", "presence-broker-binding", "wasm-sqlite-durable-adapter"];
807
+ export declare const RUNTIME_SEAMS: readonly ["sync-transport-app-tags"];
776
808
 
777
809
  export declare type RuntimeSeam = (typeof RUNTIME_SEAMS)[number];
778
810
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
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-DZzqWoJq.js";
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
3
  var g = (e) => {
4
4
  if (e.writer !== void 0) return `w:${e.writer}`;
@@ -283,10 +283,6 @@ var g = (e) => {
283
283
  "sync-transport-react-binding",
284
284
  "presence-room",
285
285
  "localFirst-mixin-discovery"
286
- ], Q = [
287
- "sync-transport-app-tags",
288
- "presence-broker-binding",
289
- "wasm-sqlite-durable-adapter"
290
- ];
286
+ ], Q = ["sync-transport-app-tags"];
291
287
  //#endregion
292
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
@@ -78,9 +78,25 @@ declare interface CrdtDocHandle {
78
78
  encodeUpdateSince: (sinceVector: Uint8Array) => CrdtState;
79
79
  /** Fold an encoded state or incremental update into this doc, in place. */
80
80
  applyState: (state: CrdtState) => void;
81
- /** Subscribe to update blobs this doc produces (local edits AND applied
82
- * remote states). Returns the unsubscribe. */
83
- onUpdate: (handler: (update: CrdtState) => void) => () => 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;
84
100
  /**
85
101
  * Encode a stable ANCHOR at `index` of the named text field — a position
86
102
  * that survives concurrent edits (the primitive an inline-comment UI pins
@@ -100,9 +116,9 @@ declare interface CrdtDocKey {
100
116
  }
101
117
 
102
118
  /**
103
- * One local edit, as {@link UseCrdtTextOptions.push} receives it: the cell it
104
- * belongs to, the encoded update to write, and how many times delivering it has
105
- * already failed.
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.
106
122
  *
107
123
  * `attempts` is carried because the sync client deliberately has no built-in
108
124
  * backoff delay — spacing or jittering a retry is the transport's lever, and it
@@ -258,6 +274,95 @@ export declare interface UseConnectionStatus {
258
274
  */
259
275
  export declare const useConnectionStatus: (outstanding?: number) => UseConnectionStatus;
260
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
+
261
366
  export declare interface UseCrdtText {
262
367
  /** The merged text — local edits and every peer's, converged. */
263
368
  readonly text: string;
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, c as t, i as n, l as r, n as i, p as a, r as o, s, t as c } from "./room-DZzqWoJq.js";
1
+ import { a as e, c as t, i as n, l as r, n as i, p as a, r as o, s, t as c } from "./room-24dGMM80.js";
2
2
  import { useCallback as l, useEffect as u, useMemo as d, useReducer as f, useRef as p, useState as m } from "react";
3
3
  //#region src/react/useSyncQueue.ts
4
4
  var h = (e = !0) => {
@@ -97,7 +97,43 @@ var h = (e = !0) => {
97
97
  _,
98
98
  f
99
99
  ]);
100
- }, x = (e, t) => {
100
+ }, x = (e) => {
101
+ let { cell: t } = e, n = null, r = i({
102
+ transport: {
103
+ kind: "sync-transport",
104
+ push: async (t) => {
105
+ await e.push({
106
+ table: t.payload.table,
107
+ id: t.payload.id,
108
+ column: t.payload.column,
109
+ update: t.payload.update,
110
+ attempts: t.attempts
111
+ });
112
+ },
113
+ onRemoteState: (e) => (n = (n) => {
114
+ e({
115
+ ...t,
116
+ state: n
117
+ });
118
+ }, () => {
119
+ n = null;
120
+ })
121
+ },
122
+ ...e.adapter === void 0 ? {} : { adapter: e.adapter },
123
+ ...e.backend === void 0 ? {} : { backend: e.backend },
124
+ ...e.online === void 0 ? {} : { online: e.online },
125
+ ...e.maxAttempts === void 0 ? {} : { maxAttempts: e.maxAttempts }
126
+ });
127
+ return {
128
+ client: r,
129
+ deliverRemote: (e) => {
130
+ n?.(e);
131
+ },
132
+ close: () => {
133
+ r.close();
134
+ }
135
+ };
136
+ }, S = (e) => `${e.table}\u0000${e.id}\u0000${e.column}`, C = (e, t) => {
101
137
  if (e === t) return null;
102
138
  let n = Math.min(e.length, t.length), r = 0;
103
139
  for (; r < n && e[r] === t[r];) r += 1;
@@ -108,71 +144,53 @@ var h = (e = !0) => {
108
144
  deleteLength: e.length - r - i,
109
145
  insert: t.slice(r, t.length - i)
110
146
  };
111
- }, S = (e) => `${e.table}\u0000${e.id}\u0000${e.column}`, C = {
147
+ }, w = {
112
148
  text: "",
113
149
  state: void 0,
114
150
  outstanding: 0
115
- }, w = (e) => {
116
- let { cell: t, remote: n, adapter: r, online: o, maxAttempts: s } = e, c = e.backend ?? a, f = S(t), h = p(e.push);
117
- h.current = e.push;
118
- let g = p(t);
119
- g.current = t;
120
- let _ = p(null), v = p(null), y = p(null), [b, w] = m(C);
151
+ }, T = (e) => {
152
+ let { cell: t, remote: n, adapter: r, online: i, maxAttempts: o } = e, s = e.backend ?? a, c = S(t), f = p(e.push);
153
+ f.current = e.push;
154
+ let h = p(t);
155
+ h.current = t;
156
+ let g = p(null), _ = p(null), v = p(null), [y, b] = m(w);
121
157
  u(() => {
122
- let t = g.current, n = i({
123
- transport: {
124
- kind: "sync-transport",
125
- push: async (e) => {
126
- await h.current({
127
- table: e.payload.table,
128
- id: e.payload.id,
129
- column: e.payload.column,
130
- update: e.payload.update,
131
- attempts: e.attempts
132
- });
133
- },
134
- onRemoteState: (e) => (v.current = (n) => {
135
- e({
136
- ...t,
137
- state: n
138
- });
139
- }, () => {
140
- v.current = null;
141
- })
142
- },
158
+ let t = h.current, n = x({
159
+ cell: t,
160
+ push: (e) => f.current(e),
143
161
  ...r === void 0 ? {} : { adapter: r },
144
162
  ...e.backend === void 0 ? {} : { backend: e.backend },
145
- ...o === void 0 ? {} : { online: o },
146
- ...s === void 0 ? {} : { maxAttempts: s }
147
- });
148
- _.current = n, y.current = c.createText();
149
- let a = () => {
150
- let e = n.getText(t), r = n.outstanding();
151
- w((i) => i.text === e && i.outstanding === r ? i : {
163
+ ...i === void 0 ? {} : { online: i },
164
+ ...o === void 0 ? {} : { maxAttempts: o }
165
+ }), a = n.client;
166
+ g.current = a, _.current = n.deliverRemote, v.current = s.createText();
167
+ let c = () => {
168
+ let e = a.getText(t), n = a.outstanding();
169
+ b((r) => r.text === e && r.outstanding === n ? r : {
152
170
  text: e,
153
- state: n.getState(t),
154
- outstanding: r
171
+ state: a.getState(t),
172
+ outstanding: n
155
173
  });
156
- }, l = n.subscribe(a);
157
- return a(), () => {
158
- l(), n.close(), _.current = null, y.current = null, v.current = null, w(C);
174
+ }, l = a.subscribe(c);
175
+ return c(), () => {
176
+ l(), n.close(), g.current = null, v.current = null, _.current = null, b(w);
159
177
  };
160
178
  }, [
161
- f,
162
- r,
163
179
  c,
180
+ r,
181
+ s,
164
182
  e.backend,
165
- o,
166
- s
183
+ i,
184
+ o
167
185
  ]), u(() => {
168
- n != null && v.current?.(n);
169
- }, [f, n]);
186
+ n != null && _.current?.(n);
187
+ }, [c, n]);
170
188
  let T = l((e) => {
171
- let t = _.current, n = y.current;
189
+ let t = g.current, n = v.current;
172
190
  if (t === null || n === null) return;
173
- let r = t.getState(g.current);
191
+ let r = t.getState(h.current);
174
192
  r !== void 0 && n.applyState(r), e(n), t.enqueue({
175
- ...g.current,
193
+ ...h.current,
176
194
  update: n.encodeState()
177
195
  });
178
196
  }, []), E = l((e, t) => {
@@ -180,31 +198,85 @@ var h = (e = !0) => {
180
198
  }, [T]), D = l((e, t) => {
181
199
  t <= 0 || T((n) => n.delete(e, t));
182
200
  }, [T]), O = l((e) => {
183
- let t = _.current;
201
+ let t = g.current;
184
202
  if (t === null) return;
185
- let n = x(t.getText(g.current), e);
203
+ let n = C(t.getText(h.current), e);
186
204
  n !== null && T((e) => {
187
205
  n.deleteLength > 0 && e.delete(n.index, n.deleteLength), n.insert !== "" && e.insert(n.index, n.insert);
188
206
  });
189
207
  }, [T]), k = l((e) => {
190
- _.current?.setOnline(e);
208
+ g.current?.setOnline(e);
191
209
  }, []);
192
210
  return d(() => ({
193
- text: b.text,
194
- state: b.state,
195
- outstanding: b.outstanding,
196
- synced: b.outstanding === 0,
211
+ text: y.text,
212
+ state: y.state,
213
+ outstanding: y.outstanding,
214
+ synced: y.outstanding === 0,
197
215
  insert: E,
198
216
  delete: D,
199
217
  setText: O,
200
218
  setOnline: k
201
219
  }), [
202
- b,
220
+ y,
203
221
  E,
204
222
  D,
205
223
  O,
206
224
  k
207
225
  ]);
226
+ }, E = (e) => {
227
+ let { cell: t, remote: n, adapter: r, online: i, maxAttempts: o } = e, s = e.backend ?? a, c = S(t), f = p(e.push);
228
+ f.current = e.push;
229
+ let h = p(t);
230
+ h.current = t;
231
+ let g = p(n);
232
+ g.current = n;
233
+ let _ = p(null), v = p(null), y = p(null), [b, C] = m(null), [w, T] = m(!1), [E, D] = m(0);
234
+ u(() => {
235
+ let t = h.current, n = x({
236
+ cell: t,
237
+ push: (e) => f.current(e),
238
+ ...r === void 0 ? {} : { adapter: r },
239
+ ...e.backend === void 0 ? {} : { backend: e.backend },
240
+ ...i === void 0 ? {} : { online: i },
241
+ ...o === void 0 ? {} : { maxAttempts: o }
242
+ }), a = n.client, c = s.createDoc();
243
+ _.current = a, y.current = c, v.current = n.deliverRemote;
244
+ let l = c.onUpdate((e, t) => {
245
+ t.local && a.enqueue({
246
+ ...h.current,
247
+ update: e
248
+ });
249
+ }), u = () => {
250
+ D(a.outstanding());
251
+ }, d = a.subscribe(u), p = g.current;
252
+ return p != null && (n.deliverRemote(p), c.applyState(p), T(!0)), C(c), u(), () => {
253
+ l(), d(), n.close(), _.current = null, y.current = null, v.current = null, C(null), T(!1), D(0);
254
+ };
255
+ }, [
256
+ c,
257
+ r,
258
+ s,
259
+ e.backend,
260
+ i,
261
+ o
262
+ ]), u(() => {
263
+ n != null && (v.current?.(n), y.current?.applyState(n), T(!0));
264
+ }, [c, n]);
265
+ let O = l((e) => {
266
+ _.current?.setOnline(e);
267
+ }, []);
268
+ return d(() => ({
269
+ doc: b,
270
+ loaded: w,
271
+ outstanding: E,
272
+ synced: E === 0,
273
+ setOnline: O
274
+ }), [
275
+ b,
276
+ w,
277
+ E,
278
+ O
279
+ ]);
208
280
  };
209
281
  //#endregion
210
- export { x as crdtTextEdit, _ as useConnectionStatus, w as useCrdtText, b as usePresence, h as useSyncQueue };
282
+ export { C as crdtTextEdit, _ as useConnectionStatus, E as useCrdtDoc, T as useCrdtText, b as usePresence, h as useSyncQueue };