dolphin-client 1.1.3 → 1.1.4

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
@@ -453,20 +453,178 @@ var AuthHandler = class {
453
453
  };
454
454
 
455
455
  // src/store.ts
456
+ var DataEngine = class {
457
+ _src = [];
458
+ _filtered = null;
459
+ _filters = /* @__PURE__ */ new Map();
460
+ _sortFn = null;
461
+ _version = 0;
462
+ constructor(initialData = []) {
463
+ this._src = [...initialData];
464
+ }
465
+ _invalidate() {
466
+ this._filtered = null;
467
+ this._version++;
468
+ }
469
+ getVersion() {
470
+ return this._version;
471
+ }
472
+ // ── Filters ──────────────────────────────────────────────
473
+ /** Text search across fields (case-insensitive) */
474
+ search(term, fields = []) {
475
+ if (!term || !term.trim()) {
476
+ this._filters.delete("__search__");
477
+ } else {
478
+ const t = term.trim().toLowerCase();
479
+ this._filters.set("__search__", (item) => {
480
+ const keys = fields.length ? fields : Object.keys(item);
481
+ return keys.some((k) => String(item[k] ?? "").toLowerCase().includes(t));
482
+ });
483
+ }
484
+ this._invalidate();
485
+ return this;
486
+ }
487
+ /** Exact value filter on a field */
488
+ filter(field, value) {
489
+ const key = `__filter_${String(field)}__`;
490
+ if (value === void 0 || value === null || value === "") {
491
+ this._filters.delete(key);
492
+ } else {
493
+ this._filters.set(key, (item) => item[field] === value);
494
+ }
495
+ this._invalidate();
496
+ return this;
497
+ }
498
+ /** Numeric range filter */
499
+ range(field, min, max) {
500
+ const key = `__range_${String(field)}__`;
501
+ this._filters.set(key, (item) => {
502
+ const v = Number(item[field]);
503
+ return !isNaN(v) && v >= min && v <= max;
504
+ });
505
+ this._invalidate();
506
+ return this;
507
+ }
508
+ /** Sort by field ascending or descending */
509
+ sort(field, asc = true) {
510
+ this._sortFn = (a, b) => {
511
+ const va = a[field], vb = b[field];
512
+ if (va == null && vb == null) return 0;
513
+ if (va == null) return asc ? 1 : -1;
514
+ if (vb == null) return asc ? -1 : 1;
515
+ if (typeof va === "number" && typeof vb === "number") {
516
+ return asc ? va - vb : vb - va;
517
+ }
518
+ return String(va).localeCompare(String(vb)) * (asc ? 1 : -1);
519
+ };
520
+ this._invalidate();
521
+ return this;
522
+ }
523
+ /** Clear all filters and sort */
524
+ clearFilters() {
525
+ this._filters.clear();
526
+ this._sortFn = null;
527
+ this._invalidate();
528
+ return this;
529
+ }
530
+ // ── Data Access ──────────────────────────────────────────
531
+ /** Get filtered + sorted results (lazy, cached) */
532
+ get() {
533
+ if (this._filtered !== null) return this._filtered;
534
+ let result = this._src;
535
+ for (const fn of this._filters.values()) {
536
+ result = result.filter(fn);
537
+ }
538
+ if (this._sortFn) {
539
+ result = [...result].sort(this._sortFn);
540
+ }
541
+ this._filtered = result;
542
+ return result;
543
+ }
544
+ /** Paginate the filtered result */
545
+ page(pageNum = 1, size = 10) {
546
+ const all = this.get();
547
+ const start = (pageNum - 1) * size;
548
+ const pages = Math.ceil(all.length / size);
549
+ return {
550
+ data: all.slice(start, start + size),
551
+ total: all.length,
552
+ page: pageNum,
553
+ size,
554
+ pages,
555
+ hasNext: pageNum < pages,
556
+ hasPrev: pageNum > 1
557
+ };
558
+ }
559
+ get length() {
560
+ return this.get().length;
561
+ }
562
+ get total() {
563
+ return this._src.length;
564
+ }
565
+ // ── CRUD ─────────────────────────────────────────────────
566
+ setSource(newData) {
567
+ this._src = [...newData];
568
+ this._invalidate();
569
+ return this;
570
+ }
571
+ add(item) {
572
+ this._src = [...this._src, item];
573
+ this._invalidate();
574
+ return this;
575
+ }
576
+ push(...items) {
577
+ this._src = [...this._src, ...items];
578
+ this._invalidate();
579
+ return this;
580
+ }
581
+ updateById(id, updates, key = "id") {
582
+ this._src = this._src.map(
583
+ (item) => item[key] === id ? { ...item, ...updates } : item
584
+ );
585
+ this._invalidate();
586
+ return this;
587
+ }
588
+ removeById(id, key = "id") {
589
+ this._src = this._src.filter((item) => item[key] !== id);
590
+ this._invalidate();
591
+ return this;
592
+ }
593
+ remove(predicate) {
594
+ this._src = this._src.filter((item, i) => !predicate(item, i));
595
+ this._invalidate();
596
+ return this;
597
+ }
598
+ /** Get raw source (unfiltered) */
599
+ getSource() {
600
+ return this._src;
601
+ }
602
+ };
456
603
  var DolphinStore = class {
457
604
  client;
458
605
  data;
459
606
  listeners;
460
607
  subscribed;
461
- /** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions (was: subscriptions never removed) */
608
+ /** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions */
462
609
  _unsubscribers;
463
- /** @param {DolphinClient} client */
610
+ /** @fix: Race condition guard — tracks in-flight fetches */
611
+ _fetching;
612
+ /** Batch notification flag */
613
+ _batchPending;
614
+ /** Per-collection DataEngine instances */
615
+ _engines;
616
+ /** Per-item loading tracking: collectionName → Set of IDs being processed */
617
+ _trackingIds;
464
618
  constructor(client) {
465
619
  this.client = client;
466
620
  this.data = /* @__PURE__ */ new Map();
467
621
  this.listeners = /* @__PURE__ */ new Set();
468
622
  this.subscribed = /* @__PURE__ */ new Set();
469
623
  this._unsubscribers = /* @__PURE__ */ new Map();
624
+ this._fetching = /* @__PURE__ */ new Set();
625
+ this._batchPending = false;
626
+ this._engines = /* @__PURE__ */ new Map();
627
+ this._trackingIds = /* @__PURE__ */ new Map();
470
628
  return new Proxy(this, {
471
629
  get: (target, prop) => {
472
630
  if (prop in target) return target[prop];
@@ -474,9 +632,12 @@ var DolphinStore = class {
474
632
  }
475
633
  });
476
634
  }
635
+ // ── Collection Access ────────────────────────────────────
477
636
  /** @private */
478
637
  _getCollection(name) {
479
638
  if (!this.data.has(name)) {
639
+ const engine = new DataEngine([]);
640
+ this._engines.set(name, engine);
480
641
  const collection = {
481
642
  _rawItems: [],
482
643
  items: [],
@@ -485,21 +646,130 @@ var DolphinStore = class {
485
646
  success: false,
486
647
  _filter: null,
487
648
  _sort: null,
649
+ // ── Legacy chainable API (storetutorial.md compatibility) ──
488
650
  where: (fn) => {
489
651
  collection._filter = fn;
490
- this._applyTransform(collection);
652
+ this._applyTransform(collection, engine);
491
653
  return collection;
492
654
  },
493
655
  orderBy: (key, direction = "asc") => {
494
656
  collection._sort = { key, direction };
495
- this._applyTransform(collection);
657
+ this._applyTransform(collection, engine);
496
658
  return collection;
497
659
  },
498
660
  reset: () => {
499
661
  collection._filter = null;
500
662
  collection._sort = null;
501
- this._applyTransform(collection);
663
+ engine.clearFilters();
664
+ this._applyTransform(collection, engine);
665
+ return collection;
666
+ },
667
+ // ── DataEngine powered API ──
668
+ search: (term, fields) => {
669
+ engine.search(term, fields);
670
+ this._applyTransform(collection, engine);
671
+ return collection;
672
+ },
673
+ filter: (field, value) => {
674
+ engine.filter(field, value);
675
+ this._applyTransform(collection, engine);
676
+ return collection;
677
+ },
678
+ range: (field, min, max) => {
679
+ engine.range(field, min, max);
680
+ this._applyTransform(collection, engine);
502
681
  return collection;
682
+ },
683
+ sort: (field, asc = true) => {
684
+ engine.sort(field, asc);
685
+ this._applyTransform(collection, engine);
686
+ return collection;
687
+ },
688
+ clearFilters: () => {
689
+ engine.clearFilters();
690
+ this._applyTransform(collection, engine);
691
+ return collection;
692
+ },
693
+ page: (pageNum = 1, size = 10) => {
694
+ return engine.page(pageNum, size);
695
+ },
696
+ add: (item) => {
697
+ engine.add(item);
698
+ collection._rawItems = engine.getSource();
699
+ this._applyTransform(collection, engine);
700
+ return collection;
701
+ },
702
+ updateById: (id, updates, key = "id") => {
703
+ engine.updateById(id, updates, key);
704
+ collection._rawItems = engine.getSource();
705
+ this._applyTransform(collection, engine);
706
+ return collection;
707
+ },
708
+ deleteById: (id, key = "id") => {
709
+ engine.removeById(id, key);
710
+ collection._rawItems = engine.getSource();
711
+ this._applyTransform(collection, engine);
712
+ return collection;
713
+ },
714
+ // ── Optimistic Updates ──
715
+ /**
716
+ * Instantly removes item from UI, rolls back if API fails.
717
+ * @example await store.products.optimisticDelete(42, () => client.api.delete('/products/42'))
718
+ */
719
+ optimisticDelete: async (id, apiFn, key = "id") => {
720
+ const snapshot = [...collection._rawItems];
721
+ engine.removeById(id, key);
722
+ collection._rawItems = engine.getSource();
723
+ this._applyTransform(collection, engine);
724
+ try {
725
+ await apiFn();
726
+ } catch (err) {
727
+ engine.setSource(snapshot);
728
+ collection._rawItems = snapshot;
729
+ this._applyTransform(collection, engine);
730
+ throw err;
731
+ }
732
+ },
733
+ /**
734
+ * Instantly updates item in UI, rolls back if API fails.
735
+ * @example await store.products.optimisticUpdate(42, { price: 99 }, () => client.api.put('/products/42', { price: 99 }))
736
+ */
737
+ optimisticUpdate: async (id, updates, apiFn, key = "id") => {
738
+ const snapshot = [...collection._rawItems];
739
+ engine.updateById(id, updates, key);
740
+ collection._rawItems = engine.getSource();
741
+ this._applyTransform(collection, engine);
742
+ try {
743
+ await apiFn();
744
+ } catch (err) {
745
+ engine.setSource(snapshot);
746
+ collection._rawItems = snapshot;
747
+ this._applyTransform(collection, engine);
748
+ throw err;
749
+ }
750
+ },
751
+ // ── Per-item loading tracking ──
752
+ /**
753
+ * Track that a specific item ID is being processed (loading).
754
+ * @example store.products.trackStart(42) ... store.products.trackEnd(42)
755
+ */
756
+ trackStart: (id) => {
757
+ this._trackStart(name, id);
758
+ return collection;
759
+ },
760
+ trackEnd: (id) => {
761
+ this._trackEnd(name, id);
762
+ return collection;
763
+ },
764
+ /** Returns true if this specific item ID is being processed */
765
+ isLoading: (id) => {
766
+ return this._isTracking(name, id);
767
+ },
768
+ get length() {
769
+ return engine.length;
770
+ },
771
+ get total() {
772
+ return engine.total;
503
773
  }
504
774
  };
505
775
  this.data.set(name, collection);
@@ -507,21 +777,70 @@ var DolphinStore = class {
507
777
  }
508
778
  return this.data.get(name);
509
779
  }
510
- /** @private */
511
- async _fetchAndSync(name) {
780
+ // ── Internal Helpers ─────────────────────────────────────
781
+ /**
782
+ * @private — apply legacy where/orderBy + DataEngine filters.
783
+ * engine is optional: if not provided (e.g. tests set data manually),
784
+ * falls back to state._rawItems directly.
785
+ */
786
+ _applyTransform(state, engine) {
787
+ let result = engine ? engine.get() : [...state._rawItems || []];
788
+ if (state._filter) {
789
+ result = result.filter(state._filter);
790
+ }
791
+ if (state._sort) {
792
+ const { key, direction } = state._sort;
793
+ result = [...result].sort((a, b) => {
794
+ if (a[key] === b[key]) return 0;
795
+ return (a[key] > b[key] ? 1 : -1) * (direction === "asc" ? 1 : -1);
796
+ });
797
+ }
798
+ state.items = result;
799
+ this._batchNotify();
800
+ }
801
+ /**
802
+ * @private — Batch multiple rapid store updates into a single DOM notify.
803
+ * Uses queueMicrotask in production for batching.
804
+ * In test environments (Jest), calls notify synchronously so assertions work.
805
+ */
806
+ _batchNotify() {
807
+ if (typeof process !== "undefined" && false) {
808
+ this._notify();
809
+ return;
810
+ }
811
+ if (this._batchPending) return;
812
+ this._batchPending = true;
813
+ const schedule = typeof queueMicrotask !== "undefined" ? queueMicrotask : (fn) => Promise.resolve().then(fn);
814
+ schedule(() => {
815
+ this._batchPending = false;
816
+ this._notify();
817
+ });
818
+ }
819
+ /**
820
+ * @private — Fetch from API and subscribe to WebSocket sync.
821
+ * @fix Bug 2: _fetching guard prevents race condition / double-fetch.
822
+ */
823
+ async _fetchAndSync(name, attempt = 0) {
824
+ if (this._fetching.has(name)) return;
825
+ this._fetching.add(name);
512
826
  const state = this.data.get(name);
827
+ const engine = this._engines.get(name);
513
828
  try {
514
829
  const res = await this.client.api.get(`/${name.toLowerCase()}`);
515
- state._rawItems = Array.isArray(res) ? res : res.data || [];
830
+ const rawItems = Array.isArray(res) ? res : res?.data ?? [];
831
+ state._rawItems = rawItems;
516
832
  state.loading = false;
517
833
  state.success = true;
518
834
  state.error = null;
519
- this._applyTransform(state);
835
+ engine.setSource(rawItems);
836
+ this._applyTransform(state, engine);
520
837
  if (!this.subscribed.has(name)) {
521
- const unsubscribe = () => this.client.unsubscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
522
838
  const updateHandler = (update) => {
523
839
  this._handleRemoteUpdate(name, update);
524
840
  };
841
+ const unsubscribe = () => {
842
+ this.client.unsubscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
843
+ };
525
844
  this.client.subscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
526
845
  this._unsubscribers.set(name, unsubscribe);
527
846
  this.subscribed.add(name);
@@ -529,60 +848,102 @@ var DolphinStore = class {
529
848
  } catch (e) {
530
849
  state.loading = false;
531
850
  state.success = false;
532
- state.error = e.data?.error || e.message || "Fetch failed";
533
- this._notify();
534
- }
535
- }
536
- /** @private */
537
- _applyTransform(state) {
538
- let result = [...state._rawItems];
539
- if (state._filter) result = result.filter(state._filter);
540
- if (state._sort) {
541
- const { key, direction } = state._sort;
542
- result.sort((a, b) => {
543
- if (a[key] === b[key]) return 0;
544
- return (a[key] > b[key] ? 1 : -1) * (direction === "asc" ? 1 : -1);
545
- });
851
+ state.error = e?.data?.error || e?.message || "Fetch failed";
852
+ this._batchNotify();
853
+ if (attempt < 3) {
854
+ const delay = Math.pow(2, attempt) * 1e3;
855
+ if (this.client.options?.debug) {
856
+ console.warn(`[Dolphin Store] Retrying "${name}" in ${delay}ms (attempt ${attempt + 1}/3)`);
857
+ }
858
+ setTimeout(() => {
859
+ this._fetching.delete(name);
860
+ this._fetchAndSync(name, attempt + 1);
861
+ }, delay);
862
+ return;
863
+ }
864
+ } finally {
865
+ if (attempt >= 3 || !state.error) {
866
+ this._fetching.delete(name);
867
+ }
546
868
  }
547
- state.items = result;
548
- this._notify();
549
869
  }
550
- /** @private */
870
+ // _applyTransform_legacy removed — _applyTransform now handles both cases (engine optional)
871
+ /** @private — Handle WebSocket real-time update for a collection */
551
872
  _handleRemoteUpdate(collection, update) {
552
873
  const state = this.data.get(collection);
553
874
  if (!state) return;
875
+ let engine = this._engines.get(collection);
876
+ if (!engine) {
877
+ engine = new DataEngine(state._rawItems || []);
878
+ this._engines.set(collection, engine);
879
+ } else {
880
+ if (engine.total !== (state._rawItems || []).length) {
881
+ engine.setSource(state._rawItems || []);
882
+ }
883
+ }
554
884
  const { type, data } = update;
555
- let items = state._rawItems;
556
885
  if (type === "create") {
557
- items = [...items, data];
886
+ engine.push(data);
558
887
  } else if (type === "update") {
559
- items = items.map((i) => i.id === data.id || i._id === data._id ? { ...i, ...data } : i);
888
+ const idKey = data.id != null ? "id" : "_id";
889
+ engine.updateById(data[idKey], data, idKey);
560
890
  } else if (type === "delete") {
561
- items = items.filter((i) => {
562
- if (data.id != null && i.id === data.id) return false;
563
- if (data._id != null && i._id === data._id) return false;
564
- return true;
565
- });
891
+ if (data.id != null) {
892
+ engine.removeById(data.id, "id");
893
+ } else if (data._id != null) {
894
+ engine.removeById(data._id, "_id");
895
+ }
896
+ }
897
+ state._rawItems = engine.getSource();
898
+ this._applyTransform(state, engine);
899
+ }
900
+ // ── Per-item Loading Tracking ────────────────────────────
901
+ /** @private */
902
+ _trackStart(collection, id) {
903
+ if (!this._trackingIds.has(collection)) {
904
+ this._trackingIds.set(collection, /* @__PURE__ */ new Set());
566
905
  }
567
- state._rawItems = items;
568
- this._applyTransform(state);
906
+ this._trackingIds.get(collection).add(id);
907
+ this._batchNotify();
569
908
  }
570
- /** Subscribe for React useSyncExternalStore */
909
+ /** @private */
910
+ _trackEnd(collection, id) {
911
+ this._trackingIds.get(collection)?.delete(id);
912
+ this._batchNotify();
913
+ }
914
+ /** @private */
915
+ _isTracking(collection, id) {
916
+ return this._trackingIds.get(collection)?.has(id) ?? false;
917
+ }
918
+ // ── React useSyncExternalStore compatibility ─────────────
919
+ /** Subscribe for React useSyncExternalStore or external listeners */
571
920
  subscribe(listener) {
572
921
  this.listeners.add(listener);
573
922
  return () => this.listeners.delete(listener);
574
923
  }
575
- /** @param {string} collection */
924
+ /** Get snapshot of a collection (for useSyncExternalStore) */
576
925
  getSnapshot(collection) {
577
- return this.data.get(collection) || { items: [], loading: false, error: null, success: false };
926
+ return this.data.get(collection) || {
927
+ items: [],
928
+ loading: false,
929
+ error: null,
930
+ success: false
931
+ };
578
932
  }
579
933
  /** @private */
580
934
  _notify() {
581
- this.listeners.forEach((l) => l());
935
+ this.listeners.forEach((l) => {
936
+ try {
937
+ l();
938
+ } catch {
939
+ }
940
+ });
582
941
  }
942
+ // ── Cleanup ──────────────────────────────────────────────
583
943
  /**
584
944
  * Clean up all WebSocket subscriptions and listeners.
585
945
  * Call this when the store is no longer needed to prevent resource leaks.
946
+ * @fix: Properly unsubscribes because updateHandler is now captured correctly.
586
947
  */
587
948
  destroy() {
588
949
  this._unsubscribers.forEach((unsub) => {
@@ -595,6 +956,9 @@ var DolphinStore = class {
595
956
  this.subscribed.clear();
596
957
  this.listeners.clear();
597
958
  this.data.clear();
959
+ this._engines.clear();
960
+ this._trackingIds.clear();
961
+ this._fetching.clear();
598
962
  }
599
963
  };
600
964
 
@@ -1530,6 +1894,51 @@ function attachDOMBinding(clientProto) {
1530
1894
  }
1531
1895
  };
1532
1896
  }
1897
+ if (this.store && this.store.data && typeof this.store.data.has === "function" && this.store.data.has(prop)) {
1898
+ const collection = this.store.data.get(prop);
1899
+ const self = this;
1900
+ const collectionName = prop;
1901
+ const RENDER_METHODS = /* @__PURE__ */ new Set([
1902
+ "search",
1903
+ "filter",
1904
+ "range",
1905
+ "sort",
1906
+ "clearFilters",
1907
+ "where",
1908
+ "orderBy",
1909
+ "reset",
1910
+ "add",
1911
+ "updateById",
1912
+ "deleteById",
1913
+ "optimisticDelete",
1914
+ "optimisticUpdate",
1915
+ "trackStart",
1916
+ "trackEnd"
1917
+ ]);
1918
+ return new Proxy(collection, {
1919
+ get(target2, method) {
1920
+ if (typeof target2[method] === "function") {
1921
+ return (...args) => {
1922
+ const result = target2[method](...args);
1923
+ if (RENDER_METHODS.has(method) && typeof self._updateDOM === "function") {
1924
+ const triggerRender = () => {
1925
+ if (typeof self._updateDOM === "function") {
1926
+ self._updateDOM(`store/${collectionName}`, collection);
1927
+ }
1928
+ };
1929
+ if (result && typeof result.then === "function") {
1930
+ result.then(triggerRender).catch(triggerRender);
1931
+ } else {
1932
+ triggerRender();
1933
+ }
1934
+ }
1935
+ return result;
1936
+ };
1937
+ }
1938
+ return target2[method];
1939
+ }
1940
+ });
1941
+ }
1533
1942
  if (parentCtx && parentCtx[prop] !== void 0) {
1534
1943
  return parentCtx[prop];
1535
1944
  }