dolphin-client 1.1.2 → 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/.vscode/dolphin-tags.json +8 -0
- package/README.md +60 -16
- package/dist/dolphin-client.js +552 -50
- package/dist/dolphin-client.min.js +19 -19
- package/dist/index.cjs +552 -50
- package/dist/index.js +552 -50
- package/dist/store.d.ts +84 -10
- package/dist/testing.d.ts +5 -5
- package/fulltutorial.md +163 -9
- package/package.json +1 -1
package/dist/dolphin-client.js
CHANGED
|
@@ -453,20 +453,178 @@ var DolphinModule = (() => {
|
|
|
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
|
|
608
|
+
/** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions */
|
|
462
609
|
_unsubscribers;
|
|
463
|
-
/** @
|
|
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 DolphinModule = (() => {
|
|
|
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 DolphinModule = (() => {
|
|
|
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
|
-
|
|
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);
|
|
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);
|
|
502
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 DolphinModule = (() => {
|
|
|
507
777
|
}
|
|
508
778
|
return this.data.get(name);
|
|
509
779
|
}
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 DolphinModule = (() => {
|
|
|
529
848
|
} catch (e) {
|
|
530
849
|
state.loading = false;
|
|
531
850
|
state.success = false;
|
|
532
|
-
state.error = e
|
|
533
|
-
this.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
+
engine.push(data);
|
|
558
887
|
} else if (type === "update") {
|
|
559
|
-
|
|
888
|
+
const idKey = data.id != null ? "id" : "_id";
|
|
889
|
+
engine.updateById(data[idKey], data, idKey);
|
|
560
890
|
} else if (type === "delete") {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
568
|
-
this.
|
|
906
|
+
this._trackingIds.get(collection).add(id);
|
|
907
|
+
this._batchNotify();
|
|
908
|
+
}
|
|
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;
|
|
569
917
|
}
|
|
570
|
-
|
|
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
|
-
/**
|
|
924
|
+
/** Get snapshot of a collection (for useSyncExternalStore) */
|
|
576
925
|
getSnapshot(collection) {
|
|
577
|
-
return this.data.get(collection) || {
|
|
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) =>
|
|
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 DolphinModule = (() => {
|
|
|
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
|
|
|
@@ -1381,6 +1745,73 @@ var DolphinModule = (() => {
|
|
|
1381
1745
|
};
|
|
1382
1746
|
clientProto._scanStoreBinds = function() {
|
|
1383
1747
|
if (typeof document === "undefined") return;
|
|
1748
|
+
const storeElements = document.querySelectorAll("dolphin-store");
|
|
1749
|
+
storeElements.forEach((el) => {
|
|
1750
|
+
if (typeof el.getAttribute !== "function") return;
|
|
1751
|
+
const storeName = el.getAttribute("name") || el.getAttribute("data-store");
|
|
1752
|
+
if (!storeName) return;
|
|
1753
|
+
const hasChildren = el.children && el.children.length > 0;
|
|
1754
|
+
if (hasChildren) {
|
|
1755
|
+
if (typeof el.setAttribute === "function") {
|
|
1756
|
+
el.setAttribute("data-rt-bind", `store/${storeName}`);
|
|
1757
|
+
el.setAttribute("data-rt-type", "context");
|
|
1758
|
+
}
|
|
1759
|
+
} else {
|
|
1760
|
+
if (el.style) {
|
|
1761
|
+
el.style.display = "none";
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (!hasChildren) {
|
|
1765
|
+
const content = el.textContent ? el.textContent.trim() : "";
|
|
1766
|
+
if (content && content.startsWith("{")) {
|
|
1767
|
+
try {
|
|
1768
|
+
const parsed = JSON.parse(content);
|
|
1769
|
+
if (parsed && typeof parsed === "object") {
|
|
1770
|
+
Object.keys(parsed).forEach((key) => {
|
|
1771
|
+
this.setStoreState(storeName, key, parsed[key]);
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
} catch (err) {
|
|
1775
|
+
console.error(`[Dolphin Store Init Error] Failed to parse JSON inside <dolphin-store name="${storeName}">:`, err);
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
const templateSelector = el.getAttribute("template");
|
|
1780
|
+
if (el.attributes) {
|
|
1781
|
+
const excludeAttrs = ["name", "data-store", "style", "data-rt-bind", "data-rt-type", "template"];
|
|
1782
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
1783
|
+
if (!excludeAttrs.includes(attr.name)) {
|
|
1784
|
+
let val = attr.value;
|
|
1785
|
+
if (val === "true") val = true;
|
|
1786
|
+
else if (val === "false") val = false;
|
|
1787
|
+
else if (val === "null") val = null;
|
|
1788
|
+
else if (!isNaN(Number(val)) && val.trim() !== "") val = Number(val);
|
|
1789
|
+
this.setStoreState(storeName, attr.name, val);
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
if (templateSelector && !hasChildren && el.parentNode && typeof document !== "undefined") {
|
|
1794
|
+
const markerId = `_ds_${storeName}_${templateSelector.replace(/[^a-z0-9]/gi, "_")}`;
|
|
1795
|
+
let wrapper = document.querySelector(`[data-ds-wired="${markerId}"]`);
|
|
1796
|
+
if (!wrapper) {
|
|
1797
|
+
wrapper = document.createElement("div");
|
|
1798
|
+
wrapper.setAttribute("data-rt-bind", `store/${storeName}`);
|
|
1799
|
+
wrapper.setAttribute("data-rt-template", templateSelector);
|
|
1800
|
+
wrapper.setAttribute("data-ds-wired", markerId);
|
|
1801
|
+
el.parentNode.insertBefore(wrapper, el.nextSibling);
|
|
1802
|
+
}
|
|
1803
|
+
if (typeof this._updateDOM === "function") {
|
|
1804
|
+
this.uiStores = this.uiStores || /* @__PURE__ */ new Map();
|
|
1805
|
+
const currentStore = this.uiStores.get(storeName) || {};
|
|
1806
|
+
this._updateDOM(`store/${storeName}`, currentStore);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (hasChildren && typeof this._updateDOM === "function") {
|
|
1810
|
+
this.uiStores = this.uiStores || /* @__PURE__ */ new Map();
|
|
1811
|
+
const currentStore = this.uiStores.get(storeName) || {};
|
|
1812
|
+
this._updateDOM(`store/${storeName}`, currentStore);
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1384
1815
|
const writeEls = document.querySelectorAll("[data-store-write]");
|
|
1385
1816
|
writeEls.forEach((el) => {
|
|
1386
1817
|
const writeBind = el.getAttribute("data-store-write");
|
|
@@ -1463,6 +1894,51 @@ var DolphinModule = (() => {
|
|
|
1463
1894
|
}
|
|
1464
1895
|
};
|
|
1465
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
|
+
}
|
|
1466
1942
|
if (parentCtx && parentCtx[prop] !== void 0) {
|
|
1467
1943
|
return parentCtx[prop];
|
|
1468
1944
|
}
|
|
@@ -2786,6 +3262,32 @@ var DolphinModule = (() => {
|
|
|
2786
3262
|
}
|
|
2787
3263
|
|
|
2788
3264
|
// src/testing.ts
|
|
3265
|
+
function createMockFn() {
|
|
3266
|
+
if (typeof jest !== "undefined" && typeof jest.fn === "function") {
|
|
3267
|
+
return jest.fn();
|
|
3268
|
+
}
|
|
3269
|
+
const fn = (...args) => {
|
|
3270
|
+
fn.mock.calls.push(args);
|
|
3271
|
+
if (fn._implementation) {
|
|
3272
|
+
return fn._implementation(...args);
|
|
3273
|
+
}
|
|
3274
|
+
return fn._returnValue;
|
|
3275
|
+
};
|
|
3276
|
+
fn.mock = {
|
|
3277
|
+
calls: []
|
|
3278
|
+
};
|
|
3279
|
+
fn._returnValue = void 0;
|
|
3280
|
+
fn._implementation = null;
|
|
3281
|
+
fn.mockReturnValue = (val) => {
|
|
3282
|
+
fn._returnValue = val;
|
|
3283
|
+
return fn;
|
|
3284
|
+
};
|
|
3285
|
+
fn.mockImplementation = (impl) => {
|
|
3286
|
+
fn._implementation = impl;
|
|
3287
|
+
return fn;
|
|
3288
|
+
};
|
|
3289
|
+
return fn;
|
|
3290
|
+
}
|
|
2789
3291
|
var DolphinTestUtils = class {
|
|
2790
3292
|
static render(html) {
|
|
2791
3293
|
if (typeof document === "undefined") {
|
|
@@ -2812,11 +3314,11 @@ var DolphinModule = (() => {
|
|
|
2812
3314
|
send: (data) => {
|
|
2813
3315
|
sentMessages.push(data);
|
|
2814
3316
|
},
|
|
2815
|
-
close:
|
|
2816
|
-
onopen:
|
|
2817
|
-
onmessage:
|
|
2818
|
-
onclose:
|
|
2819
|
-
onerror:
|
|
3317
|
+
close: createMockFn(),
|
|
3318
|
+
onopen: createMockFn(),
|
|
3319
|
+
onmessage: createMockFn(),
|
|
3320
|
+
onclose: createMockFn(),
|
|
3321
|
+
onerror: createMockFn(),
|
|
2820
3322
|
sentMessages
|
|
2821
3323
|
};
|
|
2822
3324
|
global.WebSocket = class {
|
|
@@ -2851,8 +3353,8 @@ var DolphinModule = (() => {
|
|
|
2851
3353
|
static simulateClick(el) {
|
|
2852
3354
|
const clickEvt = {
|
|
2853
3355
|
target: el,
|
|
2854
|
-
preventDefault:
|
|
2855
|
-
stopPropagation:
|
|
3356
|
+
preventDefault: createMockFn(),
|
|
3357
|
+
stopPropagation: createMockFn()
|
|
2856
3358
|
};
|
|
2857
3359
|
const clickListeners = global.document._listeners?.["click"] || [];
|
|
2858
3360
|
clickListeners.forEach((listener) => listener(clickEvt));
|
|
@@ -2861,8 +3363,8 @@ var DolphinModule = (() => {
|
|
|
2861
3363
|
el.value = value;
|
|
2862
3364
|
const changeEvt = {
|
|
2863
3365
|
target: el,
|
|
2864
|
-
preventDefault:
|
|
2865
|
-
stopPropagation:
|
|
3366
|
+
preventDefault: createMockFn(),
|
|
3367
|
+
stopPropagation: createMockFn()
|
|
2866
3368
|
};
|
|
2867
3369
|
const changeListeners = global.document._listeners?.["change"] || [];
|
|
2868
3370
|
changeListeners.forEach((listener) => listener(changeEvt));
|