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/index.js
CHANGED
|
@@ -428,20 +428,178 @@ var AuthHandler = class {
|
|
|
428
428
|
};
|
|
429
429
|
|
|
430
430
|
// src/store.ts
|
|
431
|
+
var DataEngine = class {
|
|
432
|
+
_src = [];
|
|
433
|
+
_filtered = null;
|
|
434
|
+
_filters = /* @__PURE__ */ new Map();
|
|
435
|
+
_sortFn = null;
|
|
436
|
+
_version = 0;
|
|
437
|
+
constructor(initialData = []) {
|
|
438
|
+
this._src = [...initialData];
|
|
439
|
+
}
|
|
440
|
+
_invalidate() {
|
|
441
|
+
this._filtered = null;
|
|
442
|
+
this._version++;
|
|
443
|
+
}
|
|
444
|
+
getVersion() {
|
|
445
|
+
return this._version;
|
|
446
|
+
}
|
|
447
|
+
// ── Filters ──────────────────────────────────────────────
|
|
448
|
+
/** Text search across fields (case-insensitive) */
|
|
449
|
+
search(term, fields = []) {
|
|
450
|
+
if (!term || !term.trim()) {
|
|
451
|
+
this._filters.delete("__search__");
|
|
452
|
+
} else {
|
|
453
|
+
const t = term.trim().toLowerCase();
|
|
454
|
+
this._filters.set("__search__", (item) => {
|
|
455
|
+
const keys = fields.length ? fields : Object.keys(item);
|
|
456
|
+
return keys.some((k) => String(item[k] ?? "").toLowerCase().includes(t));
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
this._invalidate();
|
|
460
|
+
return this;
|
|
461
|
+
}
|
|
462
|
+
/** Exact value filter on a field */
|
|
463
|
+
filter(field, value) {
|
|
464
|
+
const key = `__filter_${String(field)}__`;
|
|
465
|
+
if (value === void 0 || value === null || value === "") {
|
|
466
|
+
this._filters.delete(key);
|
|
467
|
+
} else {
|
|
468
|
+
this._filters.set(key, (item) => item[field] === value);
|
|
469
|
+
}
|
|
470
|
+
this._invalidate();
|
|
471
|
+
return this;
|
|
472
|
+
}
|
|
473
|
+
/** Numeric range filter */
|
|
474
|
+
range(field, min, max) {
|
|
475
|
+
const key = `__range_${String(field)}__`;
|
|
476
|
+
this._filters.set(key, (item) => {
|
|
477
|
+
const v = Number(item[field]);
|
|
478
|
+
return !isNaN(v) && v >= min && v <= max;
|
|
479
|
+
});
|
|
480
|
+
this._invalidate();
|
|
481
|
+
return this;
|
|
482
|
+
}
|
|
483
|
+
/** Sort by field ascending or descending */
|
|
484
|
+
sort(field, asc = true) {
|
|
485
|
+
this._sortFn = (a, b) => {
|
|
486
|
+
const va = a[field], vb = b[field];
|
|
487
|
+
if (va == null && vb == null) return 0;
|
|
488
|
+
if (va == null) return asc ? 1 : -1;
|
|
489
|
+
if (vb == null) return asc ? -1 : 1;
|
|
490
|
+
if (typeof va === "number" && typeof vb === "number") {
|
|
491
|
+
return asc ? va - vb : vb - va;
|
|
492
|
+
}
|
|
493
|
+
return String(va).localeCompare(String(vb)) * (asc ? 1 : -1);
|
|
494
|
+
};
|
|
495
|
+
this._invalidate();
|
|
496
|
+
return this;
|
|
497
|
+
}
|
|
498
|
+
/** Clear all filters and sort */
|
|
499
|
+
clearFilters() {
|
|
500
|
+
this._filters.clear();
|
|
501
|
+
this._sortFn = null;
|
|
502
|
+
this._invalidate();
|
|
503
|
+
return this;
|
|
504
|
+
}
|
|
505
|
+
// ── Data Access ──────────────────────────────────────────
|
|
506
|
+
/** Get filtered + sorted results (lazy, cached) */
|
|
507
|
+
get() {
|
|
508
|
+
if (this._filtered !== null) return this._filtered;
|
|
509
|
+
let result = this._src;
|
|
510
|
+
for (const fn of this._filters.values()) {
|
|
511
|
+
result = result.filter(fn);
|
|
512
|
+
}
|
|
513
|
+
if (this._sortFn) {
|
|
514
|
+
result = [...result].sort(this._sortFn);
|
|
515
|
+
}
|
|
516
|
+
this._filtered = result;
|
|
517
|
+
return result;
|
|
518
|
+
}
|
|
519
|
+
/** Paginate the filtered result */
|
|
520
|
+
page(pageNum = 1, size = 10) {
|
|
521
|
+
const all = this.get();
|
|
522
|
+
const start = (pageNum - 1) * size;
|
|
523
|
+
const pages = Math.ceil(all.length / size);
|
|
524
|
+
return {
|
|
525
|
+
data: all.slice(start, start + size),
|
|
526
|
+
total: all.length,
|
|
527
|
+
page: pageNum,
|
|
528
|
+
size,
|
|
529
|
+
pages,
|
|
530
|
+
hasNext: pageNum < pages,
|
|
531
|
+
hasPrev: pageNum > 1
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
get length() {
|
|
535
|
+
return this.get().length;
|
|
536
|
+
}
|
|
537
|
+
get total() {
|
|
538
|
+
return this._src.length;
|
|
539
|
+
}
|
|
540
|
+
// ── CRUD ─────────────────────────────────────────────────
|
|
541
|
+
setSource(newData) {
|
|
542
|
+
this._src = [...newData];
|
|
543
|
+
this._invalidate();
|
|
544
|
+
return this;
|
|
545
|
+
}
|
|
546
|
+
add(item) {
|
|
547
|
+
this._src = [...this._src, item];
|
|
548
|
+
this._invalidate();
|
|
549
|
+
return this;
|
|
550
|
+
}
|
|
551
|
+
push(...items) {
|
|
552
|
+
this._src = [...this._src, ...items];
|
|
553
|
+
this._invalidate();
|
|
554
|
+
return this;
|
|
555
|
+
}
|
|
556
|
+
updateById(id, updates, key = "id") {
|
|
557
|
+
this._src = this._src.map(
|
|
558
|
+
(item) => item[key] === id ? { ...item, ...updates } : item
|
|
559
|
+
);
|
|
560
|
+
this._invalidate();
|
|
561
|
+
return this;
|
|
562
|
+
}
|
|
563
|
+
removeById(id, key = "id") {
|
|
564
|
+
this._src = this._src.filter((item) => item[key] !== id);
|
|
565
|
+
this._invalidate();
|
|
566
|
+
return this;
|
|
567
|
+
}
|
|
568
|
+
remove(predicate) {
|
|
569
|
+
this._src = this._src.filter((item, i) => !predicate(item, i));
|
|
570
|
+
this._invalidate();
|
|
571
|
+
return this;
|
|
572
|
+
}
|
|
573
|
+
/** Get raw source (unfiltered) */
|
|
574
|
+
getSource() {
|
|
575
|
+
return this._src;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
431
578
|
var DolphinStore = class {
|
|
432
579
|
client;
|
|
433
580
|
data;
|
|
434
581
|
listeners;
|
|
435
582
|
subscribed;
|
|
436
|
-
/** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions
|
|
583
|
+
/** @fix: Store unsubscribe functions so destroy() can clean up WS subscriptions */
|
|
437
584
|
_unsubscribers;
|
|
438
|
-
/** @
|
|
585
|
+
/** @fix: Race condition guard — tracks in-flight fetches */
|
|
586
|
+
_fetching;
|
|
587
|
+
/** Batch notification flag */
|
|
588
|
+
_batchPending;
|
|
589
|
+
/** Per-collection DataEngine instances */
|
|
590
|
+
_engines;
|
|
591
|
+
/** Per-item loading tracking: collectionName → Set of IDs being processed */
|
|
592
|
+
_trackingIds;
|
|
439
593
|
constructor(client) {
|
|
440
594
|
this.client = client;
|
|
441
595
|
this.data = /* @__PURE__ */ new Map();
|
|
442
596
|
this.listeners = /* @__PURE__ */ new Set();
|
|
443
597
|
this.subscribed = /* @__PURE__ */ new Set();
|
|
444
598
|
this._unsubscribers = /* @__PURE__ */ new Map();
|
|
599
|
+
this._fetching = /* @__PURE__ */ new Set();
|
|
600
|
+
this._batchPending = false;
|
|
601
|
+
this._engines = /* @__PURE__ */ new Map();
|
|
602
|
+
this._trackingIds = /* @__PURE__ */ new Map();
|
|
445
603
|
return new Proxy(this, {
|
|
446
604
|
get: (target, prop) => {
|
|
447
605
|
if (prop in target) return target[prop];
|
|
@@ -449,9 +607,12 @@ var DolphinStore = class {
|
|
|
449
607
|
}
|
|
450
608
|
});
|
|
451
609
|
}
|
|
610
|
+
// ── Collection Access ────────────────────────────────────
|
|
452
611
|
/** @private */
|
|
453
612
|
_getCollection(name) {
|
|
454
613
|
if (!this.data.has(name)) {
|
|
614
|
+
const engine = new DataEngine([]);
|
|
615
|
+
this._engines.set(name, engine);
|
|
455
616
|
const collection = {
|
|
456
617
|
_rawItems: [],
|
|
457
618
|
items: [],
|
|
@@ -460,21 +621,130 @@ var DolphinStore = class {
|
|
|
460
621
|
success: false,
|
|
461
622
|
_filter: null,
|
|
462
623
|
_sort: null,
|
|
624
|
+
// ── Legacy chainable API (storetutorial.md compatibility) ──
|
|
463
625
|
where: (fn) => {
|
|
464
626
|
collection._filter = fn;
|
|
465
|
-
this._applyTransform(collection);
|
|
627
|
+
this._applyTransform(collection, engine);
|
|
466
628
|
return collection;
|
|
467
629
|
},
|
|
468
630
|
orderBy: (key, direction = "asc") => {
|
|
469
631
|
collection._sort = { key, direction };
|
|
470
|
-
this._applyTransform(collection);
|
|
632
|
+
this._applyTransform(collection, engine);
|
|
471
633
|
return collection;
|
|
472
634
|
},
|
|
473
635
|
reset: () => {
|
|
474
636
|
collection._filter = null;
|
|
475
637
|
collection._sort = null;
|
|
476
|
-
|
|
638
|
+
engine.clearFilters();
|
|
639
|
+
this._applyTransform(collection, engine);
|
|
640
|
+
return collection;
|
|
641
|
+
},
|
|
642
|
+
// ── DataEngine powered API ──
|
|
643
|
+
search: (term, fields) => {
|
|
644
|
+
engine.search(term, fields);
|
|
645
|
+
this._applyTransform(collection, engine);
|
|
646
|
+
return collection;
|
|
647
|
+
},
|
|
648
|
+
filter: (field, value) => {
|
|
649
|
+
engine.filter(field, value);
|
|
650
|
+
this._applyTransform(collection, engine);
|
|
651
|
+
return collection;
|
|
652
|
+
},
|
|
653
|
+
range: (field, min, max) => {
|
|
654
|
+
engine.range(field, min, max);
|
|
655
|
+
this._applyTransform(collection, engine);
|
|
656
|
+
return collection;
|
|
657
|
+
},
|
|
658
|
+
sort: (field, asc = true) => {
|
|
659
|
+
engine.sort(field, asc);
|
|
660
|
+
this._applyTransform(collection, engine);
|
|
661
|
+
return collection;
|
|
662
|
+
},
|
|
663
|
+
clearFilters: () => {
|
|
664
|
+
engine.clearFilters();
|
|
665
|
+
this._applyTransform(collection, engine);
|
|
666
|
+
return collection;
|
|
667
|
+
},
|
|
668
|
+
page: (pageNum = 1, size = 10) => {
|
|
669
|
+
return engine.page(pageNum, size);
|
|
670
|
+
},
|
|
671
|
+
add: (item) => {
|
|
672
|
+
engine.add(item);
|
|
673
|
+
collection._rawItems = engine.getSource();
|
|
674
|
+
this._applyTransform(collection, engine);
|
|
675
|
+
return collection;
|
|
676
|
+
},
|
|
677
|
+
updateById: (id, updates, key = "id") => {
|
|
678
|
+
engine.updateById(id, updates, key);
|
|
679
|
+
collection._rawItems = engine.getSource();
|
|
680
|
+
this._applyTransform(collection, engine);
|
|
681
|
+
return collection;
|
|
682
|
+
},
|
|
683
|
+
deleteById: (id, key = "id") => {
|
|
684
|
+
engine.removeById(id, key);
|
|
685
|
+
collection._rawItems = engine.getSource();
|
|
686
|
+
this._applyTransform(collection, engine);
|
|
687
|
+
return collection;
|
|
688
|
+
},
|
|
689
|
+
// ── Optimistic Updates ──
|
|
690
|
+
/**
|
|
691
|
+
* Instantly removes item from UI, rolls back if API fails.
|
|
692
|
+
* @example await store.products.optimisticDelete(42, () => client.api.delete('/products/42'))
|
|
693
|
+
*/
|
|
694
|
+
optimisticDelete: async (id, apiFn, key = "id") => {
|
|
695
|
+
const snapshot = [...collection._rawItems];
|
|
696
|
+
engine.removeById(id, key);
|
|
697
|
+
collection._rawItems = engine.getSource();
|
|
698
|
+
this._applyTransform(collection, engine);
|
|
699
|
+
try {
|
|
700
|
+
await apiFn();
|
|
701
|
+
} catch (err) {
|
|
702
|
+
engine.setSource(snapshot);
|
|
703
|
+
collection._rawItems = snapshot;
|
|
704
|
+
this._applyTransform(collection, engine);
|
|
705
|
+
throw err;
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
/**
|
|
709
|
+
* Instantly updates item in UI, rolls back if API fails.
|
|
710
|
+
* @example await store.products.optimisticUpdate(42, { price: 99 }, () => client.api.put('/products/42', { price: 99 }))
|
|
711
|
+
*/
|
|
712
|
+
optimisticUpdate: async (id, updates, apiFn, key = "id") => {
|
|
713
|
+
const snapshot = [...collection._rawItems];
|
|
714
|
+
engine.updateById(id, updates, key);
|
|
715
|
+
collection._rawItems = engine.getSource();
|
|
716
|
+
this._applyTransform(collection, engine);
|
|
717
|
+
try {
|
|
718
|
+
await apiFn();
|
|
719
|
+
} catch (err) {
|
|
720
|
+
engine.setSource(snapshot);
|
|
721
|
+
collection._rawItems = snapshot;
|
|
722
|
+
this._applyTransform(collection, engine);
|
|
723
|
+
throw err;
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
// ── Per-item loading tracking ──
|
|
727
|
+
/**
|
|
728
|
+
* Track that a specific item ID is being processed (loading).
|
|
729
|
+
* @example store.products.trackStart(42) ... store.products.trackEnd(42)
|
|
730
|
+
*/
|
|
731
|
+
trackStart: (id) => {
|
|
732
|
+
this._trackStart(name, id);
|
|
733
|
+
return collection;
|
|
734
|
+
},
|
|
735
|
+
trackEnd: (id) => {
|
|
736
|
+
this._trackEnd(name, id);
|
|
477
737
|
return collection;
|
|
738
|
+
},
|
|
739
|
+
/** Returns true if this specific item ID is being processed */
|
|
740
|
+
isLoading: (id) => {
|
|
741
|
+
return this._isTracking(name, id);
|
|
742
|
+
},
|
|
743
|
+
get length() {
|
|
744
|
+
return engine.length;
|
|
745
|
+
},
|
|
746
|
+
get total() {
|
|
747
|
+
return engine.total;
|
|
478
748
|
}
|
|
479
749
|
};
|
|
480
750
|
this.data.set(name, collection);
|
|
@@ -482,21 +752,70 @@ var DolphinStore = class {
|
|
|
482
752
|
}
|
|
483
753
|
return this.data.get(name);
|
|
484
754
|
}
|
|
485
|
-
|
|
486
|
-
|
|
755
|
+
// ── Internal Helpers ─────────────────────────────────────
|
|
756
|
+
/**
|
|
757
|
+
* @private — apply legacy where/orderBy + DataEngine filters.
|
|
758
|
+
* engine is optional: if not provided (e.g. tests set data manually),
|
|
759
|
+
* falls back to state._rawItems directly.
|
|
760
|
+
*/
|
|
761
|
+
_applyTransform(state, engine) {
|
|
762
|
+
let result = engine ? engine.get() : [...state._rawItems || []];
|
|
763
|
+
if (state._filter) {
|
|
764
|
+
result = result.filter(state._filter);
|
|
765
|
+
}
|
|
766
|
+
if (state._sort) {
|
|
767
|
+
const { key, direction } = state._sort;
|
|
768
|
+
result = [...result].sort((a, b) => {
|
|
769
|
+
if (a[key] === b[key]) return 0;
|
|
770
|
+
return (a[key] > b[key] ? 1 : -1) * (direction === "asc" ? 1 : -1);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
state.items = result;
|
|
774
|
+
this._batchNotify();
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* @private — Batch multiple rapid store updates into a single DOM notify.
|
|
778
|
+
* Uses queueMicrotask in production for batching.
|
|
779
|
+
* In test environments (Jest), calls notify synchronously so assertions work.
|
|
780
|
+
*/
|
|
781
|
+
_batchNotify() {
|
|
782
|
+
if (typeof process !== "undefined" && false) {
|
|
783
|
+
this._notify();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (this._batchPending) return;
|
|
787
|
+
this._batchPending = true;
|
|
788
|
+
const schedule = typeof queueMicrotask !== "undefined" ? queueMicrotask : (fn) => Promise.resolve().then(fn);
|
|
789
|
+
schedule(() => {
|
|
790
|
+
this._batchPending = false;
|
|
791
|
+
this._notify();
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* @private — Fetch from API and subscribe to WebSocket sync.
|
|
796
|
+
* @fix Bug 2: _fetching guard prevents race condition / double-fetch.
|
|
797
|
+
*/
|
|
798
|
+
async _fetchAndSync(name, attempt = 0) {
|
|
799
|
+
if (this._fetching.has(name)) return;
|
|
800
|
+
this._fetching.add(name);
|
|
487
801
|
const state = this.data.get(name);
|
|
802
|
+
const engine = this._engines.get(name);
|
|
488
803
|
try {
|
|
489
804
|
const res = await this.client.api.get(`/${name.toLowerCase()}`);
|
|
490
|
-
|
|
805
|
+
const rawItems = Array.isArray(res) ? res : res?.data ?? [];
|
|
806
|
+
state._rawItems = rawItems;
|
|
491
807
|
state.loading = false;
|
|
492
808
|
state.success = true;
|
|
493
809
|
state.error = null;
|
|
494
|
-
|
|
810
|
+
engine.setSource(rawItems);
|
|
811
|
+
this._applyTransform(state, engine);
|
|
495
812
|
if (!this.subscribed.has(name)) {
|
|
496
|
-
const unsubscribe = () => this.client.unsubscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
|
|
497
813
|
const updateHandler = (update) => {
|
|
498
814
|
this._handleRemoteUpdate(name, update);
|
|
499
815
|
};
|
|
816
|
+
const unsubscribe = () => {
|
|
817
|
+
this.client.unsubscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
|
|
818
|
+
};
|
|
500
819
|
this.client.subscribe(`db:sync/${name.toLowerCase()}`, updateHandler);
|
|
501
820
|
this._unsubscribers.set(name, unsubscribe);
|
|
502
821
|
this.subscribed.add(name);
|
|
@@ -504,60 +823,102 @@ var DolphinStore = class {
|
|
|
504
823
|
} catch (e) {
|
|
505
824
|
state.loading = false;
|
|
506
825
|
state.success = false;
|
|
507
|
-
state.error = e
|
|
508
|
-
this.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
826
|
+
state.error = e?.data?.error || e?.message || "Fetch failed";
|
|
827
|
+
this._batchNotify();
|
|
828
|
+
if (attempt < 3) {
|
|
829
|
+
const delay = Math.pow(2, attempt) * 1e3;
|
|
830
|
+
if (this.client.options?.debug) {
|
|
831
|
+
console.warn(`[Dolphin Store] Retrying "${name}" in ${delay}ms (attempt ${attempt + 1}/3)`);
|
|
832
|
+
}
|
|
833
|
+
setTimeout(() => {
|
|
834
|
+
this._fetching.delete(name);
|
|
835
|
+
this._fetchAndSync(name, attempt + 1);
|
|
836
|
+
}, delay);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
} finally {
|
|
840
|
+
if (attempt >= 3 || !state.error) {
|
|
841
|
+
this._fetching.delete(name);
|
|
842
|
+
}
|
|
521
843
|
}
|
|
522
|
-
state.items = result;
|
|
523
|
-
this._notify();
|
|
524
844
|
}
|
|
525
|
-
|
|
845
|
+
// _applyTransform_legacy removed — _applyTransform now handles both cases (engine optional)
|
|
846
|
+
/** @private — Handle WebSocket real-time update for a collection */
|
|
526
847
|
_handleRemoteUpdate(collection, update) {
|
|
527
848
|
const state = this.data.get(collection);
|
|
528
849
|
if (!state) return;
|
|
850
|
+
let engine = this._engines.get(collection);
|
|
851
|
+
if (!engine) {
|
|
852
|
+
engine = new DataEngine(state._rawItems || []);
|
|
853
|
+
this._engines.set(collection, engine);
|
|
854
|
+
} else {
|
|
855
|
+
if (engine.total !== (state._rawItems || []).length) {
|
|
856
|
+
engine.setSource(state._rawItems || []);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
529
859
|
const { type, data } = update;
|
|
530
|
-
let items = state._rawItems;
|
|
531
860
|
if (type === "create") {
|
|
532
|
-
|
|
861
|
+
engine.push(data);
|
|
533
862
|
} else if (type === "update") {
|
|
534
|
-
|
|
863
|
+
const idKey = data.id != null ? "id" : "_id";
|
|
864
|
+
engine.updateById(data[idKey], data, idKey);
|
|
535
865
|
} else if (type === "delete") {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
866
|
+
if (data.id != null) {
|
|
867
|
+
engine.removeById(data.id, "id");
|
|
868
|
+
} else if (data._id != null) {
|
|
869
|
+
engine.removeById(data._id, "_id");
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
state._rawItems = engine.getSource();
|
|
873
|
+
this._applyTransform(state, engine);
|
|
874
|
+
}
|
|
875
|
+
// ── Per-item Loading Tracking ────────────────────────────
|
|
876
|
+
/** @private */
|
|
877
|
+
_trackStart(collection, id) {
|
|
878
|
+
if (!this._trackingIds.has(collection)) {
|
|
879
|
+
this._trackingIds.set(collection, /* @__PURE__ */ new Set());
|
|
541
880
|
}
|
|
542
|
-
|
|
543
|
-
this.
|
|
881
|
+
this._trackingIds.get(collection).add(id);
|
|
882
|
+
this._batchNotify();
|
|
883
|
+
}
|
|
884
|
+
/** @private */
|
|
885
|
+
_trackEnd(collection, id) {
|
|
886
|
+
this._trackingIds.get(collection)?.delete(id);
|
|
887
|
+
this._batchNotify();
|
|
888
|
+
}
|
|
889
|
+
/** @private */
|
|
890
|
+
_isTracking(collection, id) {
|
|
891
|
+
return this._trackingIds.get(collection)?.has(id) ?? false;
|
|
544
892
|
}
|
|
545
|
-
|
|
893
|
+
// ── React useSyncExternalStore compatibility ─────────────
|
|
894
|
+
/** Subscribe for React useSyncExternalStore or external listeners */
|
|
546
895
|
subscribe(listener) {
|
|
547
896
|
this.listeners.add(listener);
|
|
548
897
|
return () => this.listeners.delete(listener);
|
|
549
898
|
}
|
|
550
|
-
/**
|
|
899
|
+
/** Get snapshot of a collection (for useSyncExternalStore) */
|
|
551
900
|
getSnapshot(collection) {
|
|
552
|
-
return this.data.get(collection) || {
|
|
901
|
+
return this.data.get(collection) || {
|
|
902
|
+
items: [],
|
|
903
|
+
loading: false,
|
|
904
|
+
error: null,
|
|
905
|
+
success: false
|
|
906
|
+
};
|
|
553
907
|
}
|
|
554
908
|
/** @private */
|
|
555
909
|
_notify() {
|
|
556
|
-
this.listeners.forEach((l) =>
|
|
910
|
+
this.listeners.forEach((l) => {
|
|
911
|
+
try {
|
|
912
|
+
l();
|
|
913
|
+
} catch {
|
|
914
|
+
}
|
|
915
|
+
});
|
|
557
916
|
}
|
|
917
|
+
// ── Cleanup ──────────────────────────────────────────────
|
|
558
918
|
/**
|
|
559
919
|
* Clean up all WebSocket subscriptions and listeners.
|
|
560
920
|
* Call this when the store is no longer needed to prevent resource leaks.
|
|
921
|
+
* @fix: Properly unsubscribes because updateHandler is now captured correctly.
|
|
561
922
|
*/
|
|
562
923
|
destroy() {
|
|
563
924
|
this._unsubscribers.forEach((unsub) => {
|
|
@@ -570,6 +931,9 @@ var DolphinStore = class {
|
|
|
570
931
|
this.subscribed.clear();
|
|
571
932
|
this.listeners.clear();
|
|
572
933
|
this.data.clear();
|
|
934
|
+
this._engines.clear();
|
|
935
|
+
this._trackingIds.clear();
|
|
936
|
+
this._fetching.clear();
|
|
573
937
|
}
|
|
574
938
|
};
|
|
575
939
|
|
|
@@ -1356,6 +1720,73 @@ function attachDOMBinding(clientProto) {
|
|
|
1356
1720
|
};
|
|
1357
1721
|
clientProto._scanStoreBinds = function() {
|
|
1358
1722
|
if (typeof document === "undefined") return;
|
|
1723
|
+
const storeElements = document.querySelectorAll("dolphin-store");
|
|
1724
|
+
storeElements.forEach((el) => {
|
|
1725
|
+
if (typeof el.getAttribute !== "function") return;
|
|
1726
|
+
const storeName = el.getAttribute("name") || el.getAttribute("data-store");
|
|
1727
|
+
if (!storeName) return;
|
|
1728
|
+
const hasChildren = el.children && el.children.length > 0;
|
|
1729
|
+
if (hasChildren) {
|
|
1730
|
+
if (typeof el.setAttribute === "function") {
|
|
1731
|
+
el.setAttribute("data-rt-bind", `store/${storeName}`);
|
|
1732
|
+
el.setAttribute("data-rt-type", "context");
|
|
1733
|
+
}
|
|
1734
|
+
} else {
|
|
1735
|
+
if (el.style) {
|
|
1736
|
+
el.style.display = "none";
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
if (!hasChildren) {
|
|
1740
|
+
const content = el.textContent ? el.textContent.trim() : "";
|
|
1741
|
+
if (content && content.startsWith("{")) {
|
|
1742
|
+
try {
|
|
1743
|
+
const parsed = JSON.parse(content);
|
|
1744
|
+
if (parsed && typeof parsed === "object") {
|
|
1745
|
+
Object.keys(parsed).forEach((key) => {
|
|
1746
|
+
this.setStoreState(storeName, key, parsed[key]);
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
} catch (err) {
|
|
1750
|
+
console.error(`[Dolphin Store Init Error] Failed to parse JSON inside <dolphin-store name="${storeName}">:`, err);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
const templateSelector = el.getAttribute("template");
|
|
1755
|
+
if (el.attributes) {
|
|
1756
|
+
const excludeAttrs = ["name", "data-store", "style", "data-rt-bind", "data-rt-type", "template"];
|
|
1757
|
+
Array.from(el.attributes).forEach((attr) => {
|
|
1758
|
+
if (!excludeAttrs.includes(attr.name)) {
|
|
1759
|
+
let val = attr.value;
|
|
1760
|
+
if (val === "true") val = true;
|
|
1761
|
+
else if (val === "false") val = false;
|
|
1762
|
+
else if (val === "null") val = null;
|
|
1763
|
+
else if (!isNaN(Number(val)) && val.trim() !== "") val = Number(val);
|
|
1764
|
+
this.setStoreState(storeName, attr.name, val);
|
|
1765
|
+
}
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
if (templateSelector && !hasChildren && el.parentNode && typeof document !== "undefined") {
|
|
1769
|
+
const markerId = `_ds_${storeName}_${templateSelector.replace(/[^a-z0-9]/gi, "_")}`;
|
|
1770
|
+
let wrapper = document.querySelector(`[data-ds-wired="${markerId}"]`);
|
|
1771
|
+
if (!wrapper) {
|
|
1772
|
+
wrapper = document.createElement("div");
|
|
1773
|
+
wrapper.setAttribute("data-rt-bind", `store/${storeName}`);
|
|
1774
|
+
wrapper.setAttribute("data-rt-template", templateSelector);
|
|
1775
|
+
wrapper.setAttribute("data-ds-wired", markerId);
|
|
1776
|
+
el.parentNode.insertBefore(wrapper, el.nextSibling);
|
|
1777
|
+
}
|
|
1778
|
+
if (typeof this._updateDOM === "function") {
|
|
1779
|
+
this.uiStores = this.uiStores || /* @__PURE__ */ new Map();
|
|
1780
|
+
const currentStore = this.uiStores.get(storeName) || {};
|
|
1781
|
+
this._updateDOM(`store/${storeName}`, currentStore);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (hasChildren && typeof this._updateDOM === "function") {
|
|
1785
|
+
this.uiStores = this.uiStores || /* @__PURE__ */ new Map();
|
|
1786
|
+
const currentStore = this.uiStores.get(storeName) || {};
|
|
1787
|
+
this._updateDOM(`store/${storeName}`, currentStore);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1359
1790
|
const writeEls = document.querySelectorAll("[data-store-write]");
|
|
1360
1791
|
writeEls.forEach((el) => {
|
|
1361
1792
|
const writeBind = el.getAttribute("data-store-write");
|
|
@@ -1438,6 +1869,51 @@ function attachDOMBinding(clientProto) {
|
|
|
1438
1869
|
}
|
|
1439
1870
|
};
|
|
1440
1871
|
}
|
|
1872
|
+
if (this.store && this.store.data && typeof this.store.data.has === "function" && this.store.data.has(prop)) {
|
|
1873
|
+
const collection = this.store.data.get(prop);
|
|
1874
|
+
const self = this;
|
|
1875
|
+
const collectionName = prop;
|
|
1876
|
+
const RENDER_METHODS = /* @__PURE__ */ new Set([
|
|
1877
|
+
"search",
|
|
1878
|
+
"filter",
|
|
1879
|
+
"range",
|
|
1880
|
+
"sort",
|
|
1881
|
+
"clearFilters",
|
|
1882
|
+
"where",
|
|
1883
|
+
"orderBy",
|
|
1884
|
+
"reset",
|
|
1885
|
+
"add",
|
|
1886
|
+
"updateById",
|
|
1887
|
+
"deleteById",
|
|
1888
|
+
"optimisticDelete",
|
|
1889
|
+
"optimisticUpdate",
|
|
1890
|
+
"trackStart",
|
|
1891
|
+
"trackEnd"
|
|
1892
|
+
]);
|
|
1893
|
+
return new Proxy(collection, {
|
|
1894
|
+
get(target2, method) {
|
|
1895
|
+
if (typeof target2[method] === "function") {
|
|
1896
|
+
return (...args) => {
|
|
1897
|
+
const result = target2[method](...args);
|
|
1898
|
+
if (RENDER_METHODS.has(method) && typeof self._updateDOM === "function") {
|
|
1899
|
+
const triggerRender = () => {
|
|
1900
|
+
if (typeof self._updateDOM === "function") {
|
|
1901
|
+
self._updateDOM(`store/${collectionName}`, collection);
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
if (result && typeof result.then === "function") {
|
|
1905
|
+
result.then(triggerRender).catch(triggerRender);
|
|
1906
|
+
} else {
|
|
1907
|
+
triggerRender();
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return result;
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
return target2[method];
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1441
1917
|
if (parentCtx && parentCtx[prop] !== void 0) {
|
|
1442
1918
|
return parentCtx[prop];
|
|
1443
1919
|
}
|
|
@@ -2761,6 +3237,32 @@ function attachPwa(clientProto) {
|
|
|
2761
3237
|
}
|
|
2762
3238
|
|
|
2763
3239
|
// src/testing.ts
|
|
3240
|
+
function createMockFn() {
|
|
3241
|
+
if (typeof jest !== "undefined" && typeof jest.fn === "function") {
|
|
3242
|
+
return jest.fn();
|
|
3243
|
+
}
|
|
3244
|
+
const fn = (...args) => {
|
|
3245
|
+
fn.mock.calls.push(args);
|
|
3246
|
+
if (fn._implementation) {
|
|
3247
|
+
return fn._implementation(...args);
|
|
3248
|
+
}
|
|
3249
|
+
return fn._returnValue;
|
|
3250
|
+
};
|
|
3251
|
+
fn.mock = {
|
|
3252
|
+
calls: []
|
|
3253
|
+
};
|
|
3254
|
+
fn._returnValue = void 0;
|
|
3255
|
+
fn._implementation = null;
|
|
3256
|
+
fn.mockReturnValue = (val) => {
|
|
3257
|
+
fn._returnValue = val;
|
|
3258
|
+
return fn;
|
|
3259
|
+
};
|
|
3260
|
+
fn.mockImplementation = (impl) => {
|
|
3261
|
+
fn._implementation = impl;
|
|
3262
|
+
return fn;
|
|
3263
|
+
};
|
|
3264
|
+
return fn;
|
|
3265
|
+
}
|
|
2764
3266
|
var DolphinTestUtils = class {
|
|
2765
3267
|
static render(html) {
|
|
2766
3268
|
if (typeof document === "undefined") {
|
|
@@ -2787,11 +3289,11 @@ var DolphinTestUtils = class {
|
|
|
2787
3289
|
send: (data) => {
|
|
2788
3290
|
sentMessages.push(data);
|
|
2789
3291
|
},
|
|
2790
|
-
close:
|
|
2791
|
-
onopen:
|
|
2792
|
-
onmessage:
|
|
2793
|
-
onclose:
|
|
2794
|
-
onerror:
|
|
3292
|
+
close: createMockFn(),
|
|
3293
|
+
onopen: createMockFn(),
|
|
3294
|
+
onmessage: createMockFn(),
|
|
3295
|
+
onclose: createMockFn(),
|
|
3296
|
+
onerror: createMockFn(),
|
|
2795
3297
|
sentMessages
|
|
2796
3298
|
};
|
|
2797
3299
|
global.WebSocket = class {
|
|
@@ -2826,8 +3328,8 @@ var DolphinTestUtils = class {
|
|
|
2826
3328
|
static simulateClick(el) {
|
|
2827
3329
|
const clickEvt = {
|
|
2828
3330
|
target: el,
|
|
2829
|
-
preventDefault:
|
|
2830
|
-
stopPropagation:
|
|
3331
|
+
preventDefault: createMockFn(),
|
|
3332
|
+
stopPropagation: createMockFn()
|
|
2831
3333
|
};
|
|
2832
3334
|
const clickListeners = global.document._listeners?.["click"] || [];
|
|
2833
3335
|
clickListeners.forEach((listener) => listener(clickEvt));
|
|
@@ -2836,8 +3338,8 @@ var DolphinTestUtils = class {
|
|
|
2836
3338
|
el.value = value;
|
|
2837
3339
|
const changeEvt = {
|
|
2838
3340
|
target: el,
|
|
2839
|
-
preventDefault:
|
|
2840
|
-
stopPropagation:
|
|
3341
|
+
preventDefault: createMockFn(),
|
|
3342
|
+
stopPropagation: createMockFn()
|
|
2841
3343
|
};
|
|
2842
3344
|
const changeListeners = global.document._listeners?.["change"] || [];
|
|
2843
3345
|
changeListeners.forEach((listener) => listener(changeEvt));
|