@korajs/react 0.3.2 → 0.5.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.cjs CHANGED
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
33
  KoraProvider: () => KoraProvider,
34
+ useApp: () => useApp,
34
35
  useCollection: () => useCollection,
35
36
  useMutation: () => useMutation,
36
37
  useQuery: () => useQuery,
@@ -49,12 +50,8 @@ function KoraProvider({
49
50
  fallback,
50
51
  children
51
52
  }) {
52
- const [resolvedStore, setResolvedStore] = (0, import_react.useState)(
53
- store ?? null
54
- );
55
- const [resolvedSync, setResolvedSync] = (0, import_react.useState)(
56
- syncEngine ?? null
57
- );
53
+ const [resolvedStore, setResolvedStore] = (0, import_react.useState)(store ?? null);
54
+ const [resolvedSync, setResolvedSync] = (0, import_react.useState)(syncEngine ?? null);
58
55
  const [ready, setReady] = (0, import_react.useState)(!app);
59
56
  const [initError, setInitError] = (0, import_react.useState)(null);
60
57
  (0, import_react.useEffect)(() => {
@@ -93,7 +90,10 @@ function KoraProvider({
93
90
  }
94
91
  const value = {
95
92
  store: resolvedStore,
96
- syncEngine: resolvedSync
93
+ syncEngine: resolvedSync,
94
+ app: app ?? null,
95
+ events: app?.events ?? null,
96
+ subscribeSyncStatus: app?.sync?.subscribeStatus ?? null
97
97
  };
98
98
  return (0, import_react.createElement)(KoraContext.Provider, { value }, children);
99
99
  }
@@ -107,6 +107,17 @@ function useKoraContext() {
107
107
  return context;
108
108
  }
109
109
 
110
+ // src/hooks/use-app.ts
111
+ function useApp() {
112
+ const { app } = useKoraContext();
113
+ if (!app) {
114
+ throw new Error(
115
+ 'useApp() requires KoraProvider to be initialized with an "app" prop. Pass your createApp() result to <KoraProvider app={app}>.'
116
+ );
117
+ }
118
+ return app;
119
+ }
120
+
110
121
  // src/hooks/use-query.ts
111
122
  var import_react2 = require("react");
112
123
 
@@ -182,6 +193,18 @@ var QueryStore = class {
182
193
  };
183
194
 
184
195
  // src/hooks/use-query.ts
196
+ var APP_NOT_READY_CODE = "APP_NOT_READY";
197
+ function assertQueryReady(query) {
198
+ const descriptor = query.getDescriptor();
199
+ if (descriptor.collection === "__pending__") {
200
+ const err = new Error(
201
+ "Cannot use useQuery() before app.ready. Await app.ready or wrap your UI in <KoraProvider app={app}>."
202
+ );
203
+ err.name = "AppNotReadyError";
204
+ Object.assign(err, { code: APP_NOT_READY_CODE });
205
+ throw err;
206
+ }
207
+ }
185
208
  var EMPTY_ARRAY2 = Object.freeze([]);
186
209
  var noopSubscribe = (_onStoreChange) => {
187
210
  return () => {
@@ -189,6 +212,9 @@ var noopSubscribe = (_onStoreChange) => {
189
212
  };
190
213
  function useQuery(query, options) {
191
214
  const enabled = options?.enabled !== false;
215
+ if (enabled) {
216
+ assertQueryReady(query);
217
+ }
192
218
  const descriptorKey = JSON.stringify(query.getDescriptor());
193
219
  const queryStoreRef = (0, import_react2.useRef)(null);
194
220
  const prevKeyRef = (0, import_react2.useRef)(null);
@@ -221,7 +247,7 @@ function useQuery(query, options) {
221
247
 
222
248
  // src/hooks/use-mutation.ts
223
249
  var import_react3 = require("react");
224
- function useMutation(mutationFn) {
250
+ function useMutation(mutationFn, options) {
225
251
  const [state, setState] = (0, import_react3.useState)({
226
252
  isLoading: false,
227
253
  error: null
@@ -235,21 +261,35 @@ function useMutation(mutationFn) {
235
261
  }, []);
236
262
  const fnRef = (0, import_react3.useRef)(mutationFn);
237
263
  fnRef.current = mutationFn;
264
+ const optionsRef = (0, import_react3.useRef)(options);
265
+ optionsRef.current = options;
238
266
  const mutateAsync = (0, import_react3.useCallback)(async (...args) => {
267
+ const opts = optionsRef.current;
268
+ let context;
239
269
  if (mountedRef.current) {
240
270
  setState({ isLoading: true, error: null });
241
271
  }
242
272
  try {
273
+ if (opts?.onMutate) {
274
+ context = await opts.onMutate(...args);
275
+ }
243
276
  const result = await fnRef.current(...args);
244
277
  if (mountedRef.current) {
245
278
  setState({ isLoading: false, error: null });
246
279
  }
280
+ opts?.onSuccess?.(result, ...args);
281
+ opts?.onSettled?.(result, null, ...args);
247
282
  return result;
248
283
  } catch (err) {
249
284
  const error = err instanceof Error ? err : new Error(String(err));
285
+ if (context !== void 0 && opts?.onRollback) {
286
+ await opts.onRollback(context, ...args);
287
+ }
250
288
  if (mountedRef.current) {
251
289
  setState({ isLoading: false, error });
252
290
  }
291
+ opts?.onError?.(error, ...args);
292
+ opts?.onSettled?.(void 0, error, ...args);
253
293
  throw error;
254
294
  }
255
295
  }, []);
@@ -276,21 +316,44 @@ function useMutation(mutationFn) {
276
316
 
277
317
  // src/hooks/use-sync-status.ts
278
318
  var import_react4 = require("react");
279
- var POLL_INTERVAL_MS = 500;
280
319
  var OFFLINE_STATUS = Object.freeze({
281
320
  status: "offline",
282
321
  pendingOperations: 0,
283
- lastSyncedAt: null
322
+ lastSyncedAt: null,
323
+ lastSuccessfulPush: null,
324
+ lastSuccessfulPull: null,
325
+ conflicts: 0
284
326
  });
327
+ var SYNC_STATUS_EVENT_TYPES = [
328
+ "sync:connected",
329
+ "sync:disconnected",
330
+ "sync:schema-mismatch",
331
+ "sync:auth-failed",
332
+ "sync:sent",
333
+ "sync:received",
334
+ "sync:acknowledged",
335
+ "sync:apply-failed",
336
+ "sync:diagnostics",
337
+ "sync:initial-sync-progress"
338
+ ];
285
339
  function useSyncStatus() {
286
- const { syncEngine } = useKoraContext();
340
+ const { syncEngine, subscribeSyncStatus, events } = useKoraContext();
287
341
  const snapshotRef = (0, import_react4.useRef)(OFFLINE_STATUS);
288
342
  const serializedRef = (0, import_react4.useRef)(JSON.stringify(OFFLINE_STATUS));
289
343
  const subscribe = (0, import_react4.useCallback)(
290
344
  (onStoreChange) => {
291
- if (!syncEngine) return () => {
292
- };
293
- const intervalId = setInterval(() => {
345
+ if (subscribeSyncStatus) {
346
+ return subscribeSyncStatus((status) => {
347
+ snapshotRef.current = status;
348
+ serializedRef.current = JSON.stringify(status);
349
+ onStoreChange();
350
+ });
351
+ }
352
+ if (!syncEngine) {
353
+ return () => {
354
+ };
355
+ }
356
+ const refresh = () => {
294
357
  const newStatus = syncEngine.getStatus();
295
358
  const newSerialized = JSON.stringify(newStatus);
296
359
  if (newSerialized !== serializedRef.current) {
@@ -298,29 +361,28 @@ function useSyncStatus() {
298
361
  serializedRef.current = newSerialized;
299
362
  onStoreChange();
300
363
  }
301
- }, POLL_INTERVAL_MS);
302
- const initialStatus = syncEngine.getStatus();
303
- const initialSerialized = JSON.stringify(initialStatus);
304
- if (initialSerialized !== serializedRef.current) {
305
- snapshotRef.current = initialStatus;
306
- serializedRef.current = initialSerialized;
307
- onStoreChange();
364
+ };
365
+ if (events) {
366
+ const unsubs = SYNC_STATUS_EVENT_TYPES.map((type) => events.on(type, refresh));
367
+ refresh();
368
+ return () => {
369
+ for (const unsub of unsubs) {
370
+ unsub();
371
+ }
372
+ };
308
373
  }
374
+ refresh();
309
375
  return () => {
310
- clearInterval(intervalId);
311
376
  };
312
377
  },
313
- [syncEngine]
378
+ [syncEngine, subscribeSyncStatus, events]
314
379
  );
315
380
  const getSnapshot = (0, import_react4.useCallback)(() => {
316
- return snapshotRef.current;
317
- }, []);
318
- (0, import_react4.useEffect)(() => {
319
- if (!syncEngine) {
320
- snapshotRef.current = OFFLINE_STATUS;
321
- serializedRef.current = JSON.stringify(OFFLINE_STATUS);
381
+ if (subscribeSyncStatus) {
382
+ return snapshotRef.current;
322
383
  }
323
- }, [syncEngine]);
384
+ return syncEngine ? syncEngine.getStatus() : OFFLINE_STATUS;
385
+ }, [syncEngine, subscribeSyncStatus]);
324
386
  return (0, import_react4.useSyncExternalStore)(subscribe, getSnapshot);
325
387
  }
326
388
 
@@ -334,21 +396,35 @@ function useCollection(name) {
334
396
  }
335
397
 
336
398
  // src/hooks/use-rich-text.ts
399
+ var import_store = require("@korajs/store");
337
400
  var import_react6 = require("react");
338
401
  var Y = __toESM(require("yjs"), 1);
339
402
  var LOAD_ORIGIN = "kora-load";
403
+ var REMOTE_ORIGIN = "kora-remote";
404
+ var DOC_CHANNEL_ORIGIN = "kora-doc-channel";
340
405
  var TEXT_KEY = "content";
341
406
  var COMPACT_AFTER_DELTAS = 20;
342
- function useRichText(collectionName, recordId, fieldName) {
343
- const { store } = useKoraContext();
344
- const collection = (0, import_react6.useMemo)(() => store.collection(collectionName), [store, collectionName]);
407
+ var PERSIST_DEBOUNCE_MS = 400;
408
+ function useRichText(collectionName, recordId, fieldName, options) {
409
+ const { store, syncEngine } = useKoraContext();
410
+ const collection = (0, import_react6.useMemo)(
411
+ () => store.collection(collectionName),
412
+ [store, collectionName]
413
+ );
345
414
  const [doc] = (0, import_react6.useState)(() => new Y.Doc());
346
415
  const [ready, setReady] = (0, import_react6.useState)(false);
347
416
  const [error, setError] = (0, import_react6.useState)(null);
348
417
  const [canUndo, setCanUndo] = (0, import_react6.useState)(false);
349
418
  const [canRedo, setCanRedo] = (0, import_react6.useState)(false);
419
+ const [cursors, setCursors] = (0, import_react6.useState)([]);
350
420
  const baseUpdateRef = (0, import_react6.useRef)(null);
351
421
  const pendingDeltasRef = (0, import_react6.useRef)([]);
422
+ const docChannelActiveRef = (0, import_react6.useRef)(false);
423
+ const persistTimerRef = (0, import_react6.useRef)(null);
424
+ const userRef = (0, import_react6.useRef)(options?.user);
425
+ (0, import_react6.useEffect)(() => {
426
+ userRef.current = options?.user;
427
+ }, [options?.user]);
352
428
  const text = (0, import_react6.useMemo)(() => doc.getText(TEXT_KEY), [doc]);
353
429
  const undoManager = (0, import_react6.useMemo)(() => new Y.UndoManager(text), [text]);
354
430
  const syncHistoryState = (0, import_react6.useCallback)(() => {
@@ -363,6 +439,72 @@ function useRichText(collectionName, recordId, fieldName) {
363
439
  undoManager.redo();
364
440
  syncHistoryState();
365
441
  }, [syncHistoryState, undoManager]);
442
+ const resolveAwarenessUser = (0, import_react6.useCallback)(() => {
443
+ if (userRef.current) {
444
+ return userRef.current;
445
+ }
446
+ if (syncEngine) {
447
+ const existing = syncEngine.getAwarenessManager().getLocalState()?.user;
448
+ if (existing) {
449
+ return existing;
450
+ }
451
+ }
452
+ return {
453
+ name: "Anonymous",
454
+ color: "#6366f1"
455
+ };
456
+ }, [syncEngine]);
457
+ const setCursor = (0, import_react6.useCallback)(
458
+ (anchor, head) => {
459
+ if (!syncEngine) {
460
+ return;
461
+ }
462
+ const awareness = syncEngine.getAwarenessManager();
463
+ const state = {
464
+ user: resolveAwarenessUser(),
465
+ cursor: {
466
+ collection: collectionName,
467
+ recordId,
468
+ field: fieldName,
469
+ anchor,
470
+ head
471
+ }
472
+ };
473
+ awareness.setLocalState(state);
474
+ },
475
+ [collectionName, fieldName, recordId, resolveAwarenessUser, syncEngine]
476
+ );
477
+ const clearCursor = (0, import_react6.useCallback)(() => {
478
+ if (!syncEngine) {
479
+ return;
480
+ }
481
+ const awareness = syncEngine.getAwarenessManager();
482
+ const current = awareness.getLocalState();
483
+ if (!current?.cursor) {
484
+ return;
485
+ }
486
+ if (current.cursor.collection !== collectionName || current.cursor.recordId !== recordId || current.cursor.field !== fieldName) {
487
+ return;
488
+ }
489
+ awareness.setLocalState({ user: current.user });
490
+ }, [collectionName, fieldName, recordId, syncEngine]);
491
+ const applyRemoteSnapshot = (0, import_react6.useCallback)(
492
+ (value) => {
493
+ const encoded = encodeRichtextInput(value);
494
+ if (!encoded) {
495
+ return;
496
+ }
497
+ const currentSnapshot = Y.encodeStateAsUpdate(doc);
498
+ if (updatesEqual(currentSnapshot, encoded)) {
499
+ return;
500
+ }
501
+ Y.applyUpdate(doc, encoded, REMOTE_ORIGIN);
502
+ baseUpdateRef.current = encoded;
503
+ pendingDeltasRef.current = [];
504
+ syncHistoryState();
505
+ },
506
+ [doc, syncHistoryState]
507
+ );
366
508
  (0, import_react6.useEffect)(() => {
367
509
  let disposed = false;
368
510
  const initialize = async () => {
@@ -381,18 +523,15 @@ function useRichText(collectionName, recordId, fieldName) {
381
523
  if (encoded) {
382
524
  Y.applyUpdate(doc, encoded, LOAD_ORIGIN);
383
525
  }
526
+ const channel = syncEngine?.getRichtextDocChannel?.();
527
+ docChannelActiveRef.current = channel?.shouldUseChannel(encoded?.length ?? 0, options?.useDocChannel) ?? false;
384
528
  setReady(true);
385
529
  } catch (cause) {
386
530
  if (disposed) return;
387
531
  setError(cause instanceof Error ? cause : new Error(String(cause)));
388
532
  }
389
533
  };
390
- const persist = async (_update, origin) => {
391
- syncHistoryState();
392
- if (origin === LOAD_ORIGIN) {
393
- return;
394
- }
395
- pendingDeltasRef.current.push(_update);
534
+ const flushPersist = async () => {
396
535
  const snapshot = composeRichtextSnapshot(baseUpdateRef.current, pendingDeltasRef.current);
397
536
  if (pendingDeltasRef.current.length >= COMPACT_AFTER_DELTAS) {
398
537
  baseUpdateRef.current = snapshot;
@@ -408,55 +547,151 @@ function useRichText(collectionName, recordId, fieldName) {
408
547
  }
409
548
  }
410
549
  };
550
+ const schedulePersist = () => {
551
+ if (persistTimerRef.current) {
552
+ clearTimeout(persistTimerRef.current);
553
+ }
554
+ persistTimerRef.current = setTimeout(() => {
555
+ persistTimerRef.current = null;
556
+ void flushPersist();
557
+ }, PERSIST_DEBOUNCE_MS);
558
+ };
559
+ const persist = (_update, origin) => {
560
+ syncHistoryState();
561
+ if (origin === LOAD_ORIGIN || origin === REMOTE_ORIGIN || origin === DOC_CHANNEL_ORIGIN) {
562
+ return;
563
+ }
564
+ pendingDeltasRef.current.push(_update);
565
+ if (docChannelActiveRef.current && syncEngine) {
566
+ syncEngine.getRichtextDocChannel().send(collectionName, recordId, fieldName, _update);
567
+ schedulePersist();
568
+ return;
569
+ }
570
+ void flushPersist();
571
+ };
411
572
  doc.on("update", persist);
412
573
  void initialize();
413
574
  syncHistoryState();
414
575
  return () => {
415
576
  disposed = true;
577
+ if (persistTimerRef.current) {
578
+ clearTimeout(persistTimerRef.current);
579
+ persistTimerRef.current = null;
580
+ }
416
581
  doc.off("update", persist);
417
582
  undoManager.destroy();
418
583
  baseUpdateRef.current = null;
419
584
  pendingDeltasRef.current = [];
585
+ docChannelActiveRef.current = false;
420
586
  };
421
- }, [collection, doc, fieldName, recordId, syncHistoryState, undoManager]);
422
- return { doc, text, undo, redo, canUndo, canRedo, ready, error };
587
+ }, [
588
+ collection,
589
+ collectionName,
590
+ doc,
591
+ fieldName,
592
+ options?.useDocChannel,
593
+ recordId,
594
+ syncEngine,
595
+ syncHistoryState,
596
+ undoManager
597
+ ]);
598
+ (0, import_react6.useEffect)(() => {
599
+ if (!ready || !syncEngine || !docChannelActiveRef.current) {
600
+ return;
601
+ }
602
+ const channel = syncEngine.getRichtextDocChannel();
603
+ return channel.subscribe(collectionName, recordId, fieldName, (update) => {
604
+ Y.applyUpdate(doc, update, DOC_CHANNEL_ORIGIN);
605
+ syncHistoryState();
606
+ });
607
+ }, [collectionName, doc, fieldName, ready, recordId, syncEngine, syncHistoryState]);
608
+ (0, import_react6.useEffect)(() => {
609
+ if (!ready) {
610
+ return;
611
+ }
612
+ const unsubscribe = store.collection(collectionName).where({ id: recordId }).subscribe((results) => {
613
+ const record = results[0];
614
+ if (!record) {
615
+ return;
616
+ }
617
+ applyRemoteSnapshot(record[fieldName]);
618
+ });
619
+ return unsubscribe;
620
+ }, [applyRemoteSnapshot, collectionName, fieldName, ready, recordId, store]);
621
+ (0, import_react6.useEffect)(() => {
622
+ if (!syncEngine) return;
623
+ const awareness = syncEngine.getAwarenessManager();
624
+ const localClientId = awareness.clientId;
625
+ const updateCursors = () => {
626
+ const states = awareness.getStates();
627
+ const fieldCursors = [];
628
+ for (const [clientId, state] of states) {
629
+ if (clientId === localClientId) continue;
630
+ if (!state.cursor) continue;
631
+ if (state.cursor.collection !== collectionName || state.cursor.recordId !== recordId || state.cursor.field !== fieldName) {
632
+ continue;
633
+ }
634
+ fieldCursors.push({
635
+ clientId,
636
+ userName: state.user.name,
637
+ color: state.user.color,
638
+ anchor: state.cursor.anchor,
639
+ head: state.cursor.head
640
+ });
641
+ }
642
+ setCursors(fieldCursors);
643
+ };
644
+ const unsubscribe = awareness.on("change", () => {
645
+ updateCursors();
646
+ });
647
+ updateCursors();
648
+ return () => {
649
+ unsubscribe();
650
+ clearCursor();
651
+ };
652
+ }, [clearCursor, collectionName, fieldName, recordId, syncEngine]);
653
+ return { doc, text, undo, redo, canUndo, canRedo, ready, error, cursors, setCursor, clearCursor };
423
654
  }
424
655
  function encodeRichtextInput(value) {
425
656
  if (value === null || value === void 0) {
426
657
  return null;
427
658
  }
428
- if (typeof value === "string") {
429
- const doc = new Y.Doc();
430
- doc.getText(TEXT_KEY).insert(0, value);
431
- return Y.encodeStateAsUpdate(doc);
432
- }
433
- if (value instanceof Uint8Array) {
434
- return value;
435
- }
436
- if (value instanceof ArrayBuffer) {
437
- return new Uint8Array(value);
438
- }
439
- if (typeof globalThis !== "undefined" && "Buffer" in globalThis) {
440
- const BufferCtor = globalThis.Buffer;
441
- if (BufferCtor.isBuffer(value)) {
442
- return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
659
+ try {
660
+ return (0, import_store.encodeRichtext)(value);
661
+ } catch {
662
+ if (typeof value === "string") {
663
+ const fallbackDoc = new Y.Doc();
664
+ fallbackDoc.getText(TEXT_KEY).insert(0, value);
665
+ return Y.encodeStateAsUpdate(fallbackDoc);
443
666
  }
667
+ throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
444
668
  }
445
- throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
446
669
  }
447
670
  function composeRichtextSnapshot(base, deltas) {
448
- const doc = new Y.Doc();
671
+ const mergedDoc = new Y.Doc();
449
672
  if (base) {
450
- Y.applyUpdate(doc, base);
673
+ Y.applyUpdate(mergedDoc, base);
451
674
  }
452
675
  for (const delta of deltas) {
453
- Y.applyUpdate(doc, delta);
676
+ Y.applyUpdate(mergedDoc, delta);
677
+ }
678
+ return Y.encodeStateAsUpdate(mergedDoc);
679
+ }
680
+ function updatesEqual(left, right) {
681
+ if (left.length !== right.length) {
682
+ return false;
683
+ }
684
+ for (let index = 0; index < left.length; index++) {
685
+ if (left[index] !== right[index]) {
686
+ return false;
687
+ }
454
688
  }
455
- return Y.encodeStateAsUpdate(doc);
689
+ return true;
456
690
  }
457
691
  // Annotate the CommonJS export names for ESM import in node:
458
692
  0 && (module.exports = {
459
693
  KoraProvider,
694
+ useApp,
460
695
  useCollection,
461
696
  useMutation,
462
697
  useQuery,