edmaxlabs-core 2.5.6 → 2.6.6

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
@@ -391,20 +391,6 @@ var DocumentSnapshot = class _DocumentSnapshot {
391
391
  }
392
392
  };
393
393
 
394
- // src/utils/documentNomalizer.ts
395
- function normalizePayload(payload) {
396
- const raw = payload?.document ?? payload?.data ?? payload;
397
- if (!raw)
398
- return null;
399
- const doc = { ...raw };
400
- doc.id = payload.id ?? payload._id;
401
- delete doc._id;
402
- return {
403
- change: payload?.change ?? raw?.change ?? null,
404
- data: doc
405
- };
406
- }
407
-
408
394
  // src/database/DocumentRef.ts
409
395
  function validateDocumentData(data, operation) {
410
396
  if (data === null || data === void 0) {
@@ -430,40 +416,22 @@ var DocumentRef = class {
430
416
  this.syncEngine = app.offline().syncEngine;
431
417
  this.localStore = app.offline().localStore;
432
418
  }
433
- // ====================== GET ======================
434
419
  async get() {
435
420
  if (this.persistence) {
436
421
  try {
437
422
  const localSnap = await this.persistence.getDocSnapshot(this.collection, this.id);
423
+ if (typeof navigator === "undefined" || navigator.onLine) {
424
+ this.refreshFromRemote().catch(() => {
425
+ });
426
+ }
438
427
  if (localSnap)
439
428
  return localSnap;
440
429
  } catch (error) {
441
430
  console.error("[EdmaxLabs] Cache read error:", error);
442
431
  }
443
432
  }
444
- try {
445
- const res = await new HttpsRequest({
446
- method: "POST" /* POST */,
447
- endpoint: `${this.app.getBaseUrl()}/db/read`,
448
- headers: {
449
- authorization: this.app.getConfig().token,
450
- "x-project": this.app.getConfig().project
451
- },
452
- body: {
453
- collection: this.collection,
454
- id: this.id,
455
- single: true
456
- }
457
- }).sendRequest();
458
- if (!res?.success || !res.document)
459
- return null;
460
- return DocumentSnapshot.fromMap(res.document);
461
- } catch (error) {
462
- console.error(`[DocumentRef] get(${this.collection}/${this.id}) failed:`, error);
463
- return null;
464
- }
433
+ return this.fetchRemoteSnapshot();
465
434
  }
466
- // ====================== UPDATE ======================
467
435
  async update(data) {
468
436
  if (this._isUpdating) {
469
437
  console.warn(`[DocumentRef] update recursion blocked on ${this.collection}/${this.id}`);
@@ -509,15 +477,13 @@ var DocumentRef = class {
509
477
  baseRevision: old.revision
510
478
  });
511
479
  const snap = DocumentSnapshot.fromMap(updated.data);
512
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
513
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "update");
480
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "update").catch(console.error);
514
481
  this.syncEngine?.flush().catch(console.error);
515
482
  return snap;
516
483
  } finally {
517
484
  this._isUpdating = false;
518
485
  }
519
486
  }
520
- // ====================== SET ======================
521
487
  async set(data) {
522
488
  validateDocumentData(data, "DocumentRef.set");
523
489
  if (!this.persistence) {
@@ -532,6 +498,8 @@ var DocumentRef = class {
532
498
  }).sendRequest();
533
499
  return res?.success ? DocumentSnapshot.fromMap({ ...data, id: this.id }) : null;
534
500
  }
501
+ const existing = await this.persistence.getDoc(this.collection, this.id);
502
+ const mutationType = existing?.exists && !existing.deleted ? "update" : "insert";
535
503
  const updated = await this.persistence.upsertDoc({
536
504
  collection: this.collection,
537
505
  id: this.id,
@@ -540,22 +508,27 @@ var DocumentRef = class {
540
508
  deleted: false,
541
509
  pending: 1,
542
510
  localOnly: false,
543
- status: "pending"
511
+ status: "pending",
512
+ revision: existing?.revision,
513
+ lastSyncedAt: existing?.lastSyncedAt
544
514
  });
545
515
  await this.persistence.enqueueMutation({
546
516
  mutationId: this.persistence.createMutationId(),
547
517
  collection: this.collection,
548
518
  documentId: this.id,
549
- type: "insert",
550
- payload: data
519
+ type: mutationType,
520
+ payload: data,
521
+ baseRevision: existing?.revision
551
522
  });
552
523
  const snap = DocumentSnapshot.fromMap(updated.data);
553
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
554
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "update");
524
+ this.app.offline().realtimeBridge?.publishLocalChange(
525
+ this.collection,
526
+ this.id,
527
+ mutationType === "insert" ? "insert" : "update"
528
+ ).catch(console.error);
555
529
  this.syncEngine?.flush().catch(console.error);
556
530
  return snap;
557
531
  }
558
- // ====================== DELETE ======================
559
532
  async delete() {
560
533
  if (this.persistence) {
561
534
  await this.persistence.markDeleted(this.collection, this.id, 1);
@@ -566,8 +539,7 @@ var DocumentRef = class {
566
539
  type: "delete",
567
540
  payload: null
568
541
  });
569
- this.localStore?.emitDocument(this.collection, this.id, null, "empty");
570
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "delete");
542
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "delete").catch(console.error);
571
543
  this.syncEngine?.flush().catch(console.error);
572
544
  return true;
573
545
  }
@@ -582,20 +554,135 @@ var DocumentRef = class {
582
554
  }).sendRequest();
583
555
  return !!res?.success;
584
556
  }
585
- // ====================== SNAPSHOT ======================
586
557
  onSnapshot(callback) {
587
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
588
- this.app.offline().realtimeBridge?.watchDocument(this.collection, this.id);
589
- return this.localStore.subscribeToDocument(this.collection, this.id, callback) || (() => {
590
- });
558
+ const realtimeBridge = this.app.offline().realtimeBridge;
559
+ if (this.persistence && this.localStore && realtimeBridge) {
560
+ const localUnsub = this.localStore.subscribeToDocument(this.collection, this.id, callback);
561
+ const remoteUnsub = realtimeBridge.watchDocument(this.collection, this.id);
562
+ this.persistence.getDocSnapshot(this.collection, this.id).then((snapshot) => callback(snapshot, "initial")).catch(console.error);
563
+ return () => {
564
+ localUnsub();
565
+ remoteUnsub();
566
+ };
591
567
  }
592
- return this.app.rtdb().subscribeToDocumentRaw(this.collection, this.id, (snapshot, change) => {
593
- const res = normalizePayload(snapshot);
594
- callback(DocumentSnapshot.fromMap(res?.data), change);
568
+ this.fetchRemoteSnapshot().then((snapshot) => callback(snapshot, "initial")).catch(console.error);
569
+ return this.app.rtdb().subscribeToDocumentRaw(this.collection, this.id, (payload, change) => {
570
+ if (change === "delete") {
571
+ callback(null, "delete");
572
+ return;
573
+ }
574
+ callback(DocumentSnapshot.fromMap(payload), change);
595
575
  });
596
576
  }
577
+ async fetchRemoteSnapshot() {
578
+ try {
579
+ const res = await new HttpsRequest({
580
+ method: "POST" /* POST */,
581
+ endpoint: `${this.app.getBaseUrl()}/db/read`,
582
+ headers: {
583
+ authorization: this.app.getConfig().token,
584
+ "x-project": this.app.getConfig().project
585
+ },
586
+ body: {
587
+ collection: this.collection,
588
+ id: this.id,
589
+ single: true
590
+ }
591
+ }).sendRequest();
592
+ if (!res?.success || !res.document)
593
+ return null;
594
+ const snapshot = DocumentSnapshot.fromMap(res.document);
595
+ if (this.persistence) {
596
+ await this.persistence.applyRemoteDoc(this.collection, snapshot.data);
597
+ }
598
+ return snapshot;
599
+ } catch (error) {
600
+ console.error(`[DocumentRef] get(${this.collection}/${this.id}) failed:`, error);
601
+ return null;
602
+ }
603
+ }
604
+ async refreshFromRemote() {
605
+ const snapshot = await this.fetchRemoteSnapshot();
606
+ if (this.persistence && snapshot) {
607
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "update").catch(console.error);
608
+ }
609
+ return snapshot;
610
+ }
597
611
  };
598
612
 
613
+ // src/database/RealtimeListeners.ts
614
+ function buildTargetKey(collection, filter = {}) {
615
+ return `${collection}:${stableStringify(filter)}`;
616
+ }
617
+ function stableStringify(value) {
618
+ if (value === null || typeof value !== "object") {
619
+ return JSON.stringify(value);
620
+ }
621
+ if (Array.isArray(value)) {
622
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
623
+ }
624
+ const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
625
+ return `{${entries.join(",")}}`;
626
+ }
627
+ function diffSnapshots(previous, next) {
628
+ const previousById = new Map(previous.map((snapshot, index) => [snapshot.id, { snapshot, index }]));
629
+ const nextById = new Map(next.map((snapshot, index) => [snapshot.id, { snapshot, index }]));
630
+ const changes = [];
631
+ next.forEach((snapshot, index) => {
632
+ const before = previousById.get(snapshot.id);
633
+ if (!before) {
634
+ changes.push({
635
+ type: "added",
636
+ doc: snapshot,
637
+ previousDoc: null,
638
+ oldIndex: -1,
639
+ newIndex: index
640
+ });
641
+ return;
642
+ }
643
+ if (before.index !== index || !areSnapshotsEqual(before.snapshot, snapshot)) {
644
+ changes.push({
645
+ type: "modified",
646
+ doc: snapshot,
647
+ previousDoc: before.snapshot,
648
+ oldIndex: before.index,
649
+ newIndex: index
650
+ });
651
+ }
652
+ });
653
+ previous.forEach((snapshot, index) => {
654
+ if (nextById.has(snapshot.id))
655
+ return;
656
+ changes.push({
657
+ type: "removed",
658
+ doc: snapshot,
659
+ previousDoc: snapshot,
660
+ oldIndex: index,
661
+ newIndex: -1
662
+ });
663
+ });
664
+ return changes;
665
+ }
666
+ function applySnapshotChange(current, incoming, source) {
667
+ if (!incoming) {
668
+ return current;
669
+ }
670
+ if (source === "delete") {
671
+ return current.filter((snapshot) => snapshot.id !== incoming.id);
672
+ }
673
+ const next = [...current];
674
+ const index = next.findIndex((snapshot) => snapshot.id === incoming.id);
675
+ if (index === -1) {
676
+ next.push(incoming);
677
+ return next;
678
+ }
679
+ next[index] = incoming;
680
+ return next;
681
+ }
682
+ function areSnapshotsEqual(left, right) {
683
+ return stableStringify(left.data) === stableStringify(right.data);
684
+ }
685
+
599
686
  // src/database/Query.ts
600
687
  var Query = class {
601
688
  constructor(app, collection) {
@@ -608,6 +695,84 @@ var Query = class {
608
695
  this.filter.push(expression);
609
696
  return this;
610
697
  }
698
+ async get() {
699
+ const persistence = this.app.offline().persistence;
700
+ if (this.filter.length > 0 && persistence) {
701
+ const local = await this.getLocalFilteredSnapshots();
702
+ if (typeof navigator === "undefined" || !navigator.onLine) {
703
+ return local;
704
+ }
705
+ return this.refreshFromRemote();
706
+ }
707
+ if (persistence) {
708
+ const local = await persistence.getCollectionSnapshots(this.collection);
709
+ if (typeof navigator === "undefined" || navigator.onLine) {
710
+ this.refreshFromRemote().catch(() => {
711
+ });
712
+ }
713
+ return local;
714
+ }
715
+ return this.refreshFromRemote();
716
+ }
717
+ async update(data) {
718
+ const genfilter = this.buildFilter();
719
+ return new HttpsRequest({
720
+ method: "POST" /* POST */,
721
+ endpoint: `${this.app.getBaseUrl()}/db/update`,
722
+ headers: { authorization: this.app.getConfig().token, "x-project": this.app.getConfig().project },
723
+ body: {
724
+ collection: this.collection,
725
+ filter: genfilter,
726
+ data
727
+ }
728
+ }).sendRequest();
729
+ }
730
+ onSnapshot(callback) {
731
+ const genfilter = this.buildFilter();
732
+ const persistence = this.app.offline().persistence;
733
+ const realtimeBridge = this.app.offline().realtimeBridge;
734
+ if (persistence && this.localStore && realtimeBridge) {
735
+ const targetKey = buildTargetKey(this.collection, genfilter);
736
+ const localUnsub = this.localStore.subscribeToCollection(targetKey, callback);
737
+ const remoteUnsub = realtimeBridge.watchCollection(
738
+ this.collection,
739
+ genfilter,
740
+ () => this.getLocalFilteredSnapshots()
741
+ );
742
+ this.getLocalFilteredSnapshots().then((snapshots) => {
743
+ realtimeBridge.primeCollectionTarget(this.collection, genfilter, snapshots);
744
+ callback(snapshots, "initial");
745
+ }).catch(console.error);
746
+ return () => {
747
+ localUnsub();
748
+ remoteUnsub();
749
+ };
750
+ }
751
+ return this.subscribeOnlineSnapshot(callback);
752
+ }
753
+ onChildListener(callbacks) {
754
+ const genfilter = this.buildFilter();
755
+ const persistence = this.app.offline().persistence;
756
+ const realtimeBridge = this.app.offline().realtimeBridge;
757
+ if (persistence && this.localStore && realtimeBridge) {
758
+ const targetKey = buildTargetKey(this.collection, genfilter);
759
+ const localUnsub = this.localStore.subscribeToChildEvents(targetKey, callbacks);
760
+ const remoteUnsub = realtimeBridge.watchCollection(
761
+ this.collection,
762
+ genfilter,
763
+ () => this.getLocalFilteredSnapshots()
764
+ );
765
+ this.getLocalFilteredSnapshots().then((snapshots) => {
766
+ realtimeBridge.primeCollectionTarget(this.collection, genfilter, snapshots);
767
+ this.emitInitialChildEvents(snapshots, callbacks);
768
+ }).catch(console.error);
769
+ return () => {
770
+ localUnsub();
771
+ remoteUnsub();
772
+ };
773
+ }
774
+ return this.subscribeOnlineChildListener(callbacks);
775
+ }
611
776
  buildFilter() {
612
777
  const mongoFilter = {};
613
778
  this.filter.forEach(({ key, op, value }) => {
@@ -678,9 +843,7 @@ var Query = class {
678
843
  if (this.filter.length === 0)
679
844
  return docs;
680
845
  return docs.filter(
681
- (snapshot) => this.filter.every(
682
- (expression) => this.matchesFilter(snapshot.data, expression)
683
- )
846
+ (snapshot) => this.filter.every((expression) => this.matchesFilter(snapshot.data, expression))
684
847
  );
685
848
  }
686
849
  async getLocalFilteredSnapshots() {
@@ -690,25 +853,6 @@ var Query = class {
690
853
  const docs = await persistence.getCollectionSnapshots(this.collection);
691
854
  return this.filterDocuments(docs);
692
855
  }
693
- async get() {
694
- const persistence = this.app.offline().persistence;
695
- if (this.filter.length > 0 && persistence) {
696
- const local = await this.getLocalFilteredSnapshots();
697
- if (typeof navigator === "undefined" || !navigator.onLine) {
698
- return local;
699
- }
700
- return this.refreshFromRemote();
701
- }
702
- if (persistence) {
703
- const local = await persistence.getCollectionSnapshots(this.collection);
704
- if (typeof navigator === "undefined" || navigator.onLine) {
705
- this.refreshFromRemote().catch(() => {
706
- });
707
- }
708
- return local;
709
- }
710
- return this.refreshFromRemote();
711
- }
712
856
  async refreshFromRemote() {
713
857
  try {
714
858
  const genfilter = this.buildFilter();
@@ -724,69 +868,90 @@ var Query = class {
724
868
  if (!res?.success || !Array.isArray(res.documents)) {
725
869
  return [];
726
870
  }
727
- return res.documents.map((d) => {
871
+ const snapshots = res.documents.map((d) => {
728
872
  delete d.token;
729
873
  d.id = d.id ?? d._id;
730
874
  delete d._id;
731
875
  return DocumentSnapshot.fromMap(d);
732
876
  });
877
+ const persistence = this.app.offline().persistence;
878
+ if (!persistence) {
879
+ return snapshots;
880
+ }
881
+ for (const snapshot of snapshots) {
882
+ await persistence.applyRemoteDoc(this.collection, snapshot.data);
883
+ }
884
+ return this.getLocalFilteredSnapshots();
733
885
  } catch {
734
886
  return [];
735
887
  }
736
888
  }
737
- // Advanced: query-wide update (keep for now, but document it's server-side only)
738
- async update(data) {
739
- const genfilter = this.buildFilter();
740
- const res = await new HttpsRequest({
741
- method: "POST" /* POST */,
742
- endpoint: `${this.app.getBaseUrl()}/db/update`,
743
- headers: { authorization: this.app.getConfig().token, "x-project": this.app.getConfig().project },
744
- body: {
745
- collection: this.collection,
746
- filter: genfilter,
747
- data
748
- }
749
- }).sendRequest();
750
- return res;
889
+ subscribeOnlineSnapshot(callback) {
890
+ let current = [];
891
+ this.refreshFromRemote().then((snapshots) => {
892
+ current = snapshots;
893
+ callback(current, "initial");
894
+ }).catch(console.error);
895
+ return this.app.rtdb().subscribeToCollectionRaw(
896
+ this.collection,
897
+ (payload, change) => {
898
+ const source = change;
899
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
900
+ const next = applySnapshotChange(current, incoming, source);
901
+ current = this.filterDocuments(next);
902
+ callback(current, source, incoming.id);
903
+ },
904
+ this.buildFilter()
905
+ );
751
906
  }
752
- onSnapshot(callback) {
753
- const genfilter = this.buildFilter();
754
- const persistence = this.app.offline().persistence;
755
- if (persistence) {
756
- const remoteUnsub = this.app.offline().realtimeBridge?.watchCollection(this.collection, genfilter);
757
- if (this.filter.length > 0) {
758
- const emitFiltered = async (change, changedDocId) => {
759
- const docs = await this.getLocalFilteredSnapshots();
760
- callback(docs, change, changedDocId);
761
- };
762
- emitFiltered("insert").catch(console.error);
763
- const localUnsub2 = this.localStore?.subscribeToCollection(
764
- this.collection,
765
- async (snapshots, change, changedDocId) => {
766
- if (snapshots.length > 0) {
767
- callback(this.filterDocuments(snapshots), change, changedDocId);
768
- } else {
769
- await emitFiltered(change, changedDocId);
770
- }
907
+ subscribeOnlineChildListener(callbacks) {
908
+ let current = [];
909
+ this.refreshFromRemote().then((snapshots) => {
910
+ current = snapshots;
911
+ this.emitInitialChildEvents(snapshots, callbacks);
912
+ }).catch(console.error);
913
+ return this.app.rtdb().subscribeToCollectionRaw(
914
+ this.collection,
915
+ (payload, change) => {
916
+ const source = change;
917
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
918
+ const next = this.filterDocuments(applySnapshotChange(current, incoming, source));
919
+ const childChanges = diffSnapshots(current, next);
920
+ current = next;
921
+ childChanges.forEach((childChange) => {
922
+ const context = {
923
+ snapshots: next,
924
+ source,
925
+ changedDocId: childChange.doc.id,
926
+ fromCache: false,
927
+ oldIndex: childChange.oldIndex,
928
+ newIndex: childChange.newIndex,
929
+ previousDoc: childChange.previousDoc
930
+ };
931
+ if (childChange.type === "added") {
932
+ callbacks.onChildAdded?.(childChange.doc, context);
933
+ } else if (childChange.type === "modified") {
934
+ callbacks.onChildUpdated?.(childChange.doc, context);
935
+ } else if (childChange.type === "removed") {
936
+ callbacks.onChildRemoved?.(childChange.doc, context);
771
937
  }
772
- ) ?? (() => {
773
938
  });
774
- return () => {
775
- localUnsub2();
776
- remoteUnsub?.();
777
- };
778
- }
779
- const localUnsub = this.localStore?.subscribeToCollection(this.collection, callback) ?? (() => {
939
+ },
940
+ this.buildFilter()
941
+ );
942
+ }
943
+ emitInitialChildEvents(snapshots, callbacks) {
944
+ diffSnapshots([], snapshots).forEach((change) => {
945
+ callbacks.onChildAdded?.(change.doc, {
946
+ snapshots,
947
+ source: "initial",
948
+ changedDocId: change.doc.id,
949
+ fromCache: true,
950
+ oldIndex: change.oldIndex,
951
+ newIndex: change.newIndex,
952
+ previousDoc: change.previousDoc
780
953
  });
781
- return () => {
782
- localUnsub();
783
- remoteUnsub?.();
784
- };
785
- }
786
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
787
- const res = normalizePayload(payload);
788
- callback([DocumentSnapshot.fromMap(res?.data)], changes);
789
- }, genfilter);
954
+ });
790
955
  }
791
956
  };
792
957
 
@@ -818,6 +983,89 @@ var CollectionRef = class {
818
983
  }
819
984
  return local;
820
985
  }
986
+ return this.fetchRemoteSnapshots();
987
+ }
988
+ async add(data) {
989
+ if (this.persistence) {
990
+ const localId = this.persistence.createLocalId();
991
+ const docRecord = await this.persistence.upsertDoc({
992
+ collection: this.collection,
993
+ id: localId,
994
+ data: { ...data, id: localId },
995
+ exists: true,
996
+ deleted: false,
997
+ pending: 1,
998
+ localOnly: true,
999
+ status: "pending"
1000
+ });
1001
+ await this.persistence.enqueueMutation({
1002
+ mutationId: this.persistence.createMutationId(),
1003
+ collection: this.collection,
1004
+ documentId: localId,
1005
+ type: "insert",
1006
+ payload: docRecord.data
1007
+ });
1008
+ const snap = DocumentSnapshot.fromMap(docRecord.data);
1009
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, localId, "insert").catch(console.error);
1010
+ this.syncEngine?.flush().catch(console.error);
1011
+ return snap;
1012
+ }
1013
+ const res = await new HttpsRequest({
1014
+ method: "POST" /* POST */,
1015
+ endpoint: `${this.app.getBaseUrl()}/db/create`,
1016
+ headers: {
1017
+ authorization: this.app.getConfig().token,
1018
+ "x-project": this.app.getConfig().project
1019
+ },
1020
+ body: { collection: this.collection, data: { ...data, id: "" } }
1021
+ }).sendRequest();
1022
+ if (!res?.success || !res.document)
1023
+ return null;
1024
+ return DocumentSnapshot.fromMap(res.document);
1025
+ }
1026
+ onSnapshot(callback) {
1027
+ const realtimeBridge = this.app.offline().realtimeBridge;
1028
+ if (this.persistence && this.localStore && realtimeBridge) {
1029
+ const targetKey = buildTargetKey(this.collection, {});
1030
+ const localUnsub = this.localStore.subscribeToCollection(targetKey, callback);
1031
+ const remoteUnsub = realtimeBridge.watchCollection(
1032
+ this.collection,
1033
+ {},
1034
+ () => this.persistence.getCollectionSnapshots(this.collection)
1035
+ );
1036
+ this.persistence.getCollectionSnapshots(this.collection).then((snapshots) => {
1037
+ realtimeBridge.primeCollectionTarget(this.collection, {}, snapshots);
1038
+ callback(snapshots, "insert");
1039
+ }).catch(console.error);
1040
+ return () => {
1041
+ localUnsub();
1042
+ remoteUnsub();
1043
+ };
1044
+ }
1045
+ return this.subscribeOnlineSnapshot(callback);
1046
+ }
1047
+ onChildListener(callbacks) {
1048
+ const realtimeBridge = this.app.offline().realtimeBridge;
1049
+ if (this.persistence && this.localStore && realtimeBridge) {
1050
+ const targetKey = buildTargetKey(this.collection, {});
1051
+ const localUnsub = this.localStore.subscribeToChildEvents(targetKey, callbacks);
1052
+ const remoteUnsub = realtimeBridge.watchCollection(
1053
+ this.collection,
1054
+ {},
1055
+ () => this.persistence.getCollectionSnapshots(this.collection)
1056
+ );
1057
+ this.persistence.getCollectionSnapshots(this.collection).then((snapshots) => {
1058
+ realtimeBridge.primeCollectionTarget(this.collection, {}, snapshots);
1059
+ this.emitInitialChildEvents(snapshots, callbacks);
1060
+ }).catch(console.error);
1061
+ return () => {
1062
+ localUnsub();
1063
+ remoteUnsub();
1064
+ };
1065
+ }
1066
+ return this.subscribeOnlineChildListener(callbacks);
1067
+ }
1068
+ async refreshFromRemote() {
821
1069
  try {
822
1070
  const res = await new HttpsRequest({
823
1071
  method: "POST" /* POST */,
@@ -834,18 +1082,25 @@ var CollectionRef = class {
834
1082
  if (!res?.success || !Array.isArray(res.documents)) {
835
1083
  return [];
836
1084
  }
837
- return res.documents.map((d) => {
838
- delete d.token;
839
- d.id = d.id ?? d._id;
840
- delete d._id;
841
- return DocumentSnapshot.fromMap(d);
1085
+ const normalized = res.documents.map((raw) => {
1086
+ const doc = { ...raw };
1087
+ doc.id = doc.id ?? doc._id;
1088
+ delete doc._id;
1089
+ return doc;
842
1090
  });
1091
+ if (this.persistence) {
1092
+ await this.persistence.reconcileCollectionFromRemote(this.collection, normalized);
1093
+ const snapshots = await this.persistence.getCollectionSnapshots(this.collection);
1094
+ this.app.offline().realtimeBridge?.primeCollectionTarget(this.collection, {}, snapshots);
1095
+ return snapshots;
1096
+ }
1097
+ return normalized.map((doc) => DocumentSnapshot.fromMap(doc));
843
1098
  } catch (error) {
844
- console.error("[EdmaxLabs] Collection get failed:", error);
1099
+ console.error("[EdmaxLabs] refreshFromRemote failed:", error);
845
1100
  return [];
846
1101
  }
847
1102
  }
848
- async refreshFromRemote() {
1103
+ async fetchRemoteSnapshots() {
849
1104
  try {
850
1105
  const res = await new HttpsRequest({
851
1106
  method: "POST" /* POST */,
@@ -862,17 +1117,6 @@ var CollectionRef = class {
862
1117
  if (!res?.success || !Array.isArray(res.documents)) {
863
1118
  return [];
864
1119
  }
865
- for (const raw of res.documents) {
866
- const doc = { ...raw };
867
- doc.id = doc.id ?? doc._id;
868
- delete doc._id;
869
- if (this.persistence) {
870
- await this.persistence.applyRemoteDoc(this.collection, doc);
871
- }
872
- }
873
- if (this.persistence) {
874
- return await this.persistence.getCollectionSnapshots(this.collection);
875
- }
876
1120
  return res.documents.map((d) => {
877
1121
  delete d.token;
878
1122
  d.id = d.id ?? d._id;
@@ -880,59 +1124,68 @@ var CollectionRef = class {
880
1124
  return DocumentSnapshot.fromMap(d);
881
1125
  });
882
1126
  } catch (error) {
883
- console.error("[EdmaxLabs] refreshFromRemote failed:", error);
1127
+ console.error("[EdmaxLabs] Collection get failed:", error);
884
1128
  return [];
885
1129
  }
886
1130
  }
887
- async add(data) {
888
- if (this.persistence) {
889
- const localId = this.persistence.createLocalId();
890
- const docRecord = await this.persistence.upsertDoc({
891
- collection: this.collection,
892
- id: localId,
893
- data: { ...data, id: localId },
894
- exists: true,
895
- deleted: false,
896
- pending: 1,
897
- localOnly: true,
898
- status: "pending"
899
- });
900
- await this.persistence.enqueueMutation({
901
- mutationId: this.persistence.createMutationId(),
902
- collection: this.collection,
903
- documentId: localId,
904
- type: "insert",
905
- payload: docRecord.data
906
- });
907
- const snap = DocumentSnapshot.fromMap(docRecord.data);
908
- this.localStore?.emitDocument(this.collection, localId, snap, "insert");
909
- this.localStore?.notifyCollectionChanged(this.collection, localId, "insert");
910
- this.syncEngine?.flush().catch(console.error);
911
- return snap;
912
- }
913
- const res = await new HttpsRequest({
914
- method: "POST" /* POST */,
915
- endpoint: `${this.app.getBaseUrl()}/db/create`,
916
- headers: {
917
- authorization: this.app.getConfig().token,
918
- "x-project": this.app.getConfig().project
919
- },
920
- body: { collection: this.collection, data: { ...data, id: "" } }
921
- // server will generate id
922
- }).sendRequest();
923
- if (!res?.success || !res.document)
924
- return null;
925
- return DocumentSnapshot.fromMap(res.document);
1131
+ subscribeOnlineSnapshot(callback) {
1132
+ let current = [];
1133
+ this.fetchRemoteSnapshots().then((snapshots) => {
1134
+ current = snapshots;
1135
+ callback(current, "initial");
1136
+ }).catch(console.error);
1137
+ return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, change) => {
1138
+ const source = change;
1139
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
1140
+ current = applySnapshotChange(current, incoming, source);
1141
+ callback(current, source, incoming.id);
1142
+ });
926
1143
  }
927
- onSnapshot(callback) {
928
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
929
- this.app.offline().realtimeBridge?.watchCollection(this.collection);
930
- return this.localStore.subscribeToCollection(this.collection, callback) ?? (() => {
1144
+ subscribeOnlineChildListener(callbacks) {
1145
+ let current = [];
1146
+ this.fetchRemoteSnapshots().then((snapshots) => {
1147
+ current = snapshots;
1148
+ this.emitInitialChildEvents(snapshots, callbacks);
1149
+ }).catch(console.error);
1150
+ return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, change) => {
1151
+ const source = change;
1152
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
1153
+ const next = applySnapshotChange(current, incoming, source);
1154
+ const childChanges = diffSnapshots(current, next);
1155
+ current = next;
1156
+ childChanges.forEach((childChange) => {
1157
+ const context = {
1158
+ snapshots: next,
1159
+ source,
1160
+ changedDocId: childChange.doc.id,
1161
+ fromCache: false,
1162
+ oldIndex: childChange.oldIndex,
1163
+ newIndex: childChange.newIndex,
1164
+ previousDoc: childChange.previousDoc
1165
+ };
1166
+ if (childChange.type === "added") {
1167
+ callbacks.onChildAdded?.(childChange.doc, context);
1168
+ } else if (childChange.type === "modified") {
1169
+ callbacks.onChildUpdated?.(childChange.doc, context);
1170
+ } else if (childChange.type === "removed") {
1171
+ callbacks.onChildRemoved?.(childChange.doc, context);
1172
+ }
931
1173
  });
932
- }
933
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
934
- const res = normalizePayload(payload);
935
- callback([DocumentSnapshot.fromMap(res?.data)], changes);
1174
+ });
1175
+ }
1176
+ emitInitialChildEvents(snapshots, callbacks) {
1177
+ const childChanges = diffSnapshots([], snapshots);
1178
+ childChanges.forEach((change) => {
1179
+ const context = {
1180
+ snapshots,
1181
+ source: "initial",
1182
+ changedDocId: change.doc.id,
1183
+ fromCache: true,
1184
+ oldIndex: change.oldIndex,
1185
+ newIndex: change.newIndex,
1186
+ previousDoc: change.previousDoc
1187
+ };
1188
+ callbacks.onChildAdded?.(change.doc, context);
936
1189
  });
937
1190
  }
938
1191
  };
@@ -982,6 +1235,7 @@ var Batch = class {
982
1235
  const persistence = this.app.offline().persistence;
983
1236
  const localStore = this.app.offline().localStore;
984
1237
  const syncEngine = this.app.offline().syncEngine;
1238
+ const realtimeBridge = this.app.offline().realtimeBridge;
985
1239
  if (persistence && localStore) {
986
1240
  const results = [];
987
1241
  for (const op of this.ops) {
@@ -1010,8 +1264,7 @@ var Batch = class {
1010
1264
  payload: op.data
1011
1265
  });
1012
1266
  const snap = DocumentSnapshot.fromMap(upserted.data);
1013
- localStore.emitDocument(op.collection, op.id, snap, "insert");
1014
- localStore.notifyCollectionChanged(op.collection, op.id, "insert");
1267
+ realtimeBridge?.publishLocalChange(op.collection, op.id, "insert").catch(console.error);
1015
1268
  results.push(snap);
1016
1269
  } else if (op.op === "delete") {
1017
1270
  const docRef = new DocumentRef(this.app, op.collection, op.id);
@@ -1123,6 +1376,20 @@ function generateUUID() {
1123
1376
  });
1124
1377
  }
1125
1378
 
1379
+ // src/utils/documentNomalizer.ts
1380
+ function normalizePayload(payload) {
1381
+ const raw = payload?.document ?? payload?.data ?? payload;
1382
+ if (!raw)
1383
+ return null;
1384
+ const doc = { ...raw };
1385
+ doc.id = doc.id ?? raw?.id ?? raw?._id ?? payload?.id ?? payload?._id;
1386
+ delete doc._id;
1387
+ return {
1388
+ change: payload?.change ?? raw?.change ?? null,
1389
+ data: doc
1390
+ };
1391
+ }
1392
+
1126
1393
  // src/database/Realtime.ts
1127
1394
  var Realtime = class {
1128
1395
  constructor(app) {
@@ -1191,8 +1458,12 @@ var Realtime = class {
1191
1458
  const channel = `${lid}-${event}`;
1192
1459
  const fn = (payload) => {
1193
1460
  const normalized = normalizePayload(payload);
1194
- if (!normalized)
1461
+ if (!normalized) {
1462
+ if (event === "delete") {
1463
+ callback(payload, "delete");
1464
+ }
1195
1465
  return;
1466
+ }
1196
1467
  callback(normalized.data, event);
1197
1468
  };
1198
1469
  this.socket.on(channel, fn);
@@ -1316,11 +1587,11 @@ var LocalStore = class {
1316
1587
  constructor() {
1317
1588
  this.documentListeners = /* @__PURE__ */ new Map();
1318
1589
  this.collectionListeners = /* @__PURE__ */ new Map();
1590
+ this.childListeners = /* @__PURE__ */ new Map();
1319
1591
  }
1320
1592
  docKey(collection, id) {
1321
1593
  return `${collection}:${id}`;
1322
1594
  }
1323
- // ===================== DOCUMENT LISTENERS =====================
1324
1595
  subscribeToDocument(collection, id, callback) {
1325
1596
  const key = this.docKey(collection, id);
1326
1597
  if (!this.documentListeners.has(key)) {
@@ -1335,24 +1606,33 @@ var LocalStore = class {
1335
1606
  }
1336
1607
  };
1337
1608
  }
1338
- // ===================== COLLECTION LISTENERS =====================
1339
- subscribeToCollection(collection, callback) {
1340
- if (!this.collectionListeners.has(collection)) {
1341
- this.collectionListeners.set(collection, /* @__PURE__ */ new Set());
1609
+ subscribeToCollection(targetKey, callback) {
1610
+ if (!this.collectionListeners.has(targetKey)) {
1611
+ this.collectionListeners.set(targetKey, /* @__PURE__ */ new Set());
1342
1612
  }
1343
- const listeners = this.collectionListeners.get(collection);
1613
+ const listeners = this.collectionListeners.get(targetKey);
1344
1614
  listeners.add(callback);
1345
1615
  return () => {
1346
1616
  listeners.delete(callback);
1347
1617
  if (listeners.size === 0) {
1348
- this.collectionListeners.delete(collection);
1618
+ this.collectionListeners.delete(targetKey);
1619
+ this.childListeners.delete(targetKey);
1620
+ }
1621
+ };
1622
+ }
1623
+ subscribeToChildEvents(targetKey, callbacks) {
1624
+ if (!this.childListeners.has(targetKey)) {
1625
+ this.childListeners.set(targetKey, /* @__PURE__ */ new Set());
1626
+ }
1627
+ const listeners = this.childListeners.get(targetKey);
1628
+ listeners.add(callbacks);
1629
+ return () => {
1630
+ listeners.delete(callbacks);
1631
+ if (listeners.size === 0) {
1632
+ this.childListeners.delete(targetKey);
1349
1633
  }
1350
1634
  };
1351
1635
  }
1352
- // ===================== EMITTERS =====================
1353
- /**
1354
- * Notify all listeners for a specific document
1355
- */
1356
1636
  emitDocument(collection, id, snapshot, change) {
1357
1637
  const key = this.docKey(collection, id);
1358
1638
  this.documentListeners.get(key)?.forEach((cb) => {
@@ -1363,64 +1643,66 @@ var LocalStore = class {
1363
1643
  }
1364
1644
  });
1365
1645
  }
1366
- /**
1367
- * Notify all listeners for a collection.
1368
- * This is the most important fix — collection listeners now receive the full list.
1369
- */
1370
- emitCollection(collection, snapshots, change, changedDocId) {
1371
- this.collectionListeners.get(collection)?.forEach((cb) => {
1646
+ emitCollection(targetKey, emission) {
1647
+ this.collectionListeners.get(targetKey)?.forEach((cb) => {
1372
1648
  try {
1373
- cb(snapshots, change, changedDocId);
1649
+ cb(emission.snapshots, emission.source, emission.changedDocId);
1374
1650
  } catch (err) {
1375
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1651
+ console.error(`[EdmaxLabs] Error in collection listener for ${targetKey}:`, err);
1376
1652
  }
1377
1653
  });
1378
- }
1379
- /**
1380
- * OPTIMIZED: Notify collection listeners that something changed, without fetching full collection.
1381
- * Listeners can call getCollectionSnapshots() themselves if they need the full list.
1382
- * This avoids expensive collection queries after every single mutation.
1383
- */
1384
- notifyCollectionChanged(collection, changedDocId, change) {
1385
- this.collectionListeners.get(collection)?.forEach((cb) => {
1386
- try {
1387
- cb([], change, changedDocId);
1388
- } catch (err) {
1389
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1390
- }
1654
+ const childChanges = emission.childChanges ?? [];
1655
+ if (childChanges.length === 0)
1656
+ return;
1657
+ this.childListeners.get(targetKey)?.forEach((callbacks) => {
1658
+ childChanges.forEach((change) => {
1659
+ this.dispatchChildEvent(callbacks, change, emission);
1660
+ });
1391
1661
  });
1392
1662
  }
1393
- // ===================== UTILITY =====================
1394
- /**
1395
- * Clear all listeners (useful for testing or when persistence is disabled)
1396
- */
1397
1663
  clearAllListeners() {
1398
1664
  this.documentListeners.clear();
1399
1665
  this.collectionListeners.clear();
1666
+ this.childListeners.clear();
1400
1667
  }
1401
- /**
1402
- * Get current listener count (for debugging / dev tools)
1403
- */
1404
1668
  get listenerCount() {
1405
1669
  let docCount = 0;
1406
1670
  this.documentListeners.forEach((set) => docCount += set.size);
1407
1671
  let collCount = 0;
1408
1672
  this.collectionListeners.forEach((set) => collCount += set.size);
1673
+ this.childListeners.forEach((set) => collCount += set.size);
1409
1674
  return { documents: docCount, collections: collCount };
1410
1675
  }
1411
- /**
1412
- * Remove all listeners for a specific collection (useful for cleanup)
1413
- */
1414
- removeCollectionListeners(collection) {
1415
- this.collectionListeners.delete(collection);
1676
+ removeCollectionListeners(targetKey) {
1677
+ this.collectionListeners.delete(targetKey);
1678
+ this.childListeners.delete(targetKey);
1416
1679
  }
1417
- /**
1418
- * Remove all listeners for a specific document (useful for cleanup)
1419
- */
1420
1680
  removeDocumentListeners(collection, id) {
1421
1681
  const key = this.docKey(collection, id);
1422
1682
  this.documentListeners.delete(key);
1423
1683
  }
1684
+ dispatchChildEvent(callbacks, change, emission) {
1685
+ const context = {
1686
+ snapshots: emission.snapshots,
1687
+ source: emission.source,
1688
+ changedDocId: emission.changedDocId,
1689
+ fromCache: emission.fromCache ?? true,
1690
+ oldIndex: change.oldIndex,
1691
+ newIndex: change.newIndex,
1692
+ previousDoc: change.previousDoc
1693
+ };
1694
+ try {
1695
+ if (change.type === "added") {
1696
+ callbacks.onChildAdded?.(change.doc, context);
1697
+ } else if (change.type === "modified") {
1698
+ callbacks.onChildUpdated?.(change.doc, context);
1699
+ } else if (change.type === "removed") {
1700
+ callbacks.onChildRemoved?.(change.doc, context);
1701
+ }
1702
+ } catch (err) {
1703
+ console.error("[EdmaxLabs] Error in child listener:", err);
1704
+ }
1705
+ }
1424
1706
  };
1425
1707
 
1426
1708
  // ../../../../node_modules/idb/build/wrap-idb-value.js
@@ -1791,11 +2073,25 @@ var Persistence = class {
1791
2073
  }
1792
2074
  async getPendingMutations() {
1793
2075
  const app = await this.getDb();
1794
- const [pending, failed] = await Promise.all([
1795
- app.getAllFromIndex("mutations", "by-status", "pending"),
1796
- app.getAllFromIndex("mutations", "by-status", "failed")
1797
- ]);
1798
- return [...pending, ...failed].sort((a, b) => a.createdAt - b.createdAt);
2076
+ const pending = await app.getAllFromIndex("mutations", "by-status", "pending");
2077
+ return pending.sort((a, b) => a.createdAt - b.createdAt);
2078
+ }
2079
+ async getFailedMutations() {
2080
+ const app = await this.getDb();
2081
+ const failed = await app.getAllFromIndex("mutations", "by-status", "failed");
2082
+ return failed.sort((a, b) => a.createdAt - b.createdAt);
2083
+ }
2084
+ async recoverSyncingMutations() {
2085
+ const app = await this.getDb();
2086
+ const syncing = await app.getAllFromIndex("mutations", "by-status", "syncing");
2087
+ for (const mutation of syncing) {
2088
+ await app.put("mutations", {
2089
+ ...mutation,
2090
+ status: "pending",
2091
+ updatedAt: this.now()
2092
+ });
2093
+ }
2094
+ return syncing.length;
1799
2095
  }
1800
2096
  async getMutation(mutationId) {
1801
2097
  const app = await this.getDb();
@@ -1900,6 +2196,27 @@ var Persistence = class {
1900
2196
  }
1901
2197
  return this.markDeleted(collection, id, 0);
1902
2198
  }
2199
+ async reconcileCollectionFromRemote(collection, documents) {
2200
+ const app = await this.getDb();
2201
+ const existing = await app.getAllFromIndex("docs", "by-collection", collection);
2202
+ const incomingIds = /* @__PURE__ */ new Set();
2203
+ for (const data of documents) {
2204
+ const id = data.id ?? data._id;
2205
+ if (!id)
2206
+ continue;
2207
+ incomingIds.add(String(id));
2208
+ await this.applyRemoteDoc(collection, { ...data, id: String(id) });
2209
+ }
2210
+ for (const doc of existing) {
2211
+ if (doc.pending && doc.pending > 0)
2212
+ continue;
2213
+ if (!doc.exists || doc.deleted)
2214
+ continue;
2215
+ if (incomingIds.has(doc.id))
2216
+ continue;
2217
+ await this.markDeleted(collection, doc.id, 0);
2218
+ }
2219
+ }
1903
2220
  // ==================== UTILITIES ====================
1904
2221
  createLocalId() {
1905
2222
  return `local_${crypto.randomUUID()}`;
@@ -1929,112 +2246,169 @@ var RealtimeBridge = class {
1929
2246
  this.app = app;
1930
2247
  this.persistence = persistence;
1931
2248
  this.store = store;
1932
- this.collectionUnsubs = /* @__PURE__ */ new Map();
1933
- this.documentUnsubs = /* @__PURE__ */ new Map();
2249
+ this.collectionTargets = /* @__PURE__ */ new Map();
2250
+ this.documentTargets = /* @__PURE__ */ new Map();
1934
2251
  }
1935
2252
  docKey(collection, id) {
1936
2253
  return `${collection}:${id}`;
1937
2254
  }
1938
- // ===================== PUBLIC API =====================
1939
- watchCollection(collection, filter = {}) {
1940
- const key = `${collection}:${JSON.stringify(filter)}`;
1941
- if (this.collectionUnsubs.has(key)) {
1942
- return this.collectionUnsubs.get(key);
2255
+ watchCollection(collection, filter = {}, read) {
2256
+ const targetKey = buildTargetKey(collection, filter);
2257
+ const existing = this.collectionTargets.get(targetKey);
2258
+ if (existing) {
2259
+ existing.refCount += 1;
2260
+ return () => this.releaseCollection(targetKey);
1943
2261
  }
1944
- this.emitCurrentCollection(collection);
1945
2262
  const unsub = this.app.rtdb().subscribeToCollectionRaw(
1946
2263
  collection,
1947
2264
  async (payload, change) => {
1948
2265
  if (change === "delete") {
1949
2266
  const id = payload?.id || payload?._id;
1950
- if (id)
2267
+ if (id) {
1951
2268
  await this.handleRemoteDelete(collection, id);
1952
- } else {
1953
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2269
+ }
2270
+ return;
1954
2271
  }
2272
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1955
2273
  },
1956
2274
  filter
1957
2275
  );
1958
- const fullUnsub = () => {
1959
- unsub();
1960
- this.collectionUnsubs.delete(key);
1961
- };
1962
- this.collectionUnsubs.set(key, fullUnsub);
1963
- return fullUnsub;
2276
+ this.collectionTargets.set(targetKey, {
2277
+ targetKey,
2278
+ collection,
2279
+ filter,
2280
+ read,
2281
+ refCount: 1,
2282
+ unsub,
2283
+ lastSnapshots: []
2284
+ });
2285
+ return () => this.releaseCollection(targetKey);
1964
2286
  }
1965
2287
  watchDocument(collection, id) {
1966
2288
  const key = this.docKey(collection, id);
1967
- if (this.documentUnsubs.has(key)) {
1968
- return this.documentUnsubs.get(key);
2289
+ const existing = this.documentTargets.get(key);
2290
+ if (existing) {
2291
+ existing.refCount += 1;
2292
+ return () => this.releaseDocument(key);
1969
2293
  }
1970
- this.emitCurrentDocument(collection, id);
1971
2294
  const unsub = this.app.rtdb().subscribeToDocumentRaw(
1972
2295
  collection,
1973
2296
  id,
1974
2297
  async (payload, change) => {
1975
2298
  if (change === "delete") {
1976
2299
  await this.handleRemoteDelete(collection, id);
1977
- } else {
1978
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2300
+ return;
1979
2301
  }
2302
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1980
2303
  }
1981
2304
  );
1982
- const fullUnsub = () => {
1983
- unsub();
1984
- this.documentUnsubs.delete(key);
1985
- };
1986
- this.documentUnsubs.set(key, fullUnsub);
1987
- return fullUnsub;
2305
+ this.documentTargets.set(key, {
2306
+ key,
2307
+ collection,
2308
+ id,
2309
+ refCount: 1,
2310
+ unsub
2311
+ });
2312
+ return () => this.releaseDocument(key);
2313
+ }
2314
+ async emitCurrentCollection(collection, filter = {}) {
2315
+ const targetKey = buildTargetKey(collection, filter);
2316
+ const target = this.collectionTargets.get(targetKey);
2317
+ if (!target)
2318
+ return [];
2319
+ return this.refreshCollectionTarget(target, "initial");
2320
+ }
2321
+ primeCollectionTarget(collection, filter, snapshots) {
2322
+ const targetKey = buildTargetKey(collection, filter);
2323
+ const target = this.collectionTargets.get(targetKey);
2324
+ if (!target)
2325
+ return;
2326
+ target.lastSnapshots = snapshots;
1988
2327
  }
1989
- // ===================== INTERNAL HANDLERS =====================
1990
2328
  async emitCurrentDocument(collection, id) {
1991
- const localDoc = await this.persistence.getDoc(collection, id);
1992
- const snapshot = localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
1993
- this.store.emitDocument(collection, id, snapshot, "insert");
2329
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2330
+ this.store.emitDocument(collection, id, snapshot, "initial");
2331
+ return snapshot;
1994
2332
  }
1995
- async emitCurrentCollection(collection) {
1996
- const localDocs = await this.persistence.getCollectionSnapshots(collection);
1997
- this.store.emitCollection(collection, localDocs, "insert");
2333
+ async publishLocalChange(collection, id, source) {
2334
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2335
+ this.store.emitDocument(collection, id, snapshot, source);
2336
+ await this.refreshCollectionTargets(collection, source, id);
2337
+ }
2338
+ async onReconnect() {
2339
+ for (const target of this.collectionTargets.values()) {
2340
+ await this.refreshCollectionTarget(target, "initial");
2341
+ }
2342
+ for (const target of this.documentTargets.values()) {
2343
+ await this.emitCurrentDocument(target.collection, target.id);
2344
+ }
2345
+ }
2346
+ dispose() {
2347
+ this.collectionTargets.forEach((target) => target.unsub());
2348
+ this.documentTargets.forEach((target) => target.unsub());
2349
+ this.collectionTargets.clear();
2350
+ this.documentTargets.clear();
1998
2351
  }
1999
- async handleRemoteCreateOrUpdate(collection, raw, change) {
2352
+ async handleRemoteCreateOrUpdate(collection, raw, source) {
2000
2353
  const id = raw.id ?? raw._id;
2001
2354
  if (!id)
2002
2355
  return;
2003
2356
  const saved = await this.persistence.applyRemoteDoc(collection, raw);
2004
- if (!saved)
2005
- return;
2006
- const snap = DocumentSnapshot.fromMap(saved.data);
2007
- this.store.emitDocument(collection, id, snap, change);
2008
- this.store.notifyCollectionChanged(collection, id, change);
2357
+ if (saved) {
2358
+ const snap = DocumentSnapshot.fromMap(saved.data);
2359
+ this.store.emitDocument(collection, id, snap, source);
2360
+ }
2361
+ await this.refreshCollectionTargets(collection, source, id, false);
2009
2362
  }
2010
2363
  async handleRemoteDelete(collection, id) {
2011
2364
  await this.persistence.applyRemoteDelete(collection, id);
2012
2365
  this.store.emitDocument(collection, id, null, "delete");
2013
- this.store.notifyCollectionChanged(collection, id, "delete");
2366
+ await this.refreshCollectionTargets(collection, "delete", id, false);
2014
2367
  }
2015
- // ===================== LIFECYCLE =====================
2016
- /**
2017
- * Call this when Socket.IO reconnects or when going online
2018
- * Replays pending mutations + refreshes all active subscriptions from cache
2019
- */
2020
- async onReconnect() {
2021
- for (const [key] of this.collectionUnsubs) {
2022
- const collection = key.split(":")[0];
2023
- await this.emitCurrentCollection(collection);
2024
- }
2025
- for (const [key] of this.documentUnsubs) {
2026
- const [collection, id] = key.split(":");
2027
- await this.emitCurrentDocument(collection, id);
2028
- }
2368
+ async getCurrentDocumentSnapshot(collection, id) {
2369
+ const localDoc = await this.persistence.getDoc(collection, id);
2370
+ return localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
2029
2371
  }
2030
- /**
2031
- * Clean up all subscriptions (call from EdmaxLabs destructor if needed)
2032
- */
2033
- dispose() {
2034
- this.collectionUnsubs.forEach((unsub) => unsub());
2035
- this.documentUnsubs.forEach((unsub) => unsub());
2036
- this.collectionUnsubs.clear();
2037
- this.documentUnsubs.clear();
2372
+ async refreshCollectionTargets(collection, source, changedDocId, fromCache = true) {
2373
+ const targets = Array.from(this.collectionTargets.values()).filter(
2374
+ (target) => target.collection === collection
2375
+ );
2376
+ for (const target of targets) {
2377
+ await this.refreshCollectionTarget(target, source, changedDocId, fromCache);
2378
+ }
2379
+ }
2380
+ async refreshCollectionTarget(target, source, changedDocId, fromCache = true) {
2381
+ const snapshots = await target.read();
2382
+ const childChanges = diffSnapshots(target.lastSnapshots, snapshots);
2383
+ target.lastSnapshots = snapshots;
2384
+ this.store.emitCollection(target.targetKey, {
2385
+ snapshots,
2386
+ source,
2387
+ changedDocId,
2388
+ fromCache,
2389
+ childChanges
2390
+ });
2391
+ return snapshots;
2392
+ }
2393
+ releaseCollection(targetKey) {
2394
+ const target = this.collectionTargets.get(targetKey);
2395
+ if (!target)
2396
+ return;
2397
+ target.refCount -= 1;
2398
+ if (target.refCount > 0)
2399
+ return;
2400
+ target.unsub();
2401
+ this.collectionTargets.delete(targetKey);
2402
+ }
2403
+ releaseDocument(key) {
2404
+ const target = this.documentTargets.get(key);
2405
+ if (!target)
2406
+ return;
2407
+ target.refCount -= 1;
2408
+ if (target.refCount > 0)
2409
+ return;
2410
+ target.unsub();
2411
+ this.documentTargets.delete(key);
2038
2412
  }
2039
2413
  };
2040
2414
 
@@ -2046,16 +2420,19 @@ var SyncEngine = class {
2046
2420
  this.retryTimeout = null;
2047
2421
  this.MAX_RETRIES = 5;
2048
2422
  this.BASE_RETRY_DELAY = 2e3;
2423
+ this.handleOnline = () => this.onNetworkOnline();
2424
+ this.handleVisibilityChange = () => {
2425
+ if (document.visibilityState === "visible")
2426
+ this.onNetworkOnline();
2427
+ };
2049
2428
  this.app = app;
2050
2429
  this.persistence = persistence;
2051
2430
  this.store = store;
2052
2431
  this.realtimeBridge = realtimeBridge || null;
2432
+ this.persistence.recoverSyncingMutations().catch(console.error);
2053
2433
  if (typeof window !== "undefined") {
2054
- window.addEventListener("online", () => this.onNetworkOnline());
2055
- document.addEventListener("visibilitychange", () => {
2056
- if (document.visibilityState === "visible")
2057
- this.onNetworkOnline();
2058
- });
2434
+ window.addEventListener("online", this.handleOnline);
2435
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
2059
2436
  }
2060
2437
  }
2061
2438
  onNetworkOnline() {
@@ -2072,6 +2449,7 @@ var SyncEngine = class {
2072
2449
  return;
2073
2450
  this.syncing = true;
2074
2451
  try {
2452
+ await this.persistence.recoverSyncingMutations();
2075
2453
  const pending = await this.persistence.getPendingMutations();
2076
2454
  for (const mutation of pending) {
2077
2455
  if (mutation.retryCount >= this.MAX_RETRIES) {
@@ -2148,8 +2526,7 @@ var SyncEngine = class {
2148
2526
  if (replaced) {
2149
2527
  const snap = DocumentSnapshot.fromMap(replaced.data);
2150
2528
  this.store.emitDocument(mutation.collection, oldId, snap, "insert");
2151
- this.store.emitDocument(mutation.collection, newId, snap, "insert");
2152
- this.store.notifyCollectionChanged(mutation.collection, newId, "insert");
2529
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, newId, "insert");
2153
2530
  }
2154
2531
  return true;
2155
2532
  }
@@ -2182,7 +2559,7 @@ var SyncEngine = class {
2182
2559
  });
2183
2560
  const snap = DocumentSnapshot.fromMap(local.data);
2184
2561
  this.store.emitDocument(mutation.collection, mutation.documentId, snap, "update");
2185
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "update");
2562
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "update");
2186
2563
  return true;
2187
2564
  }
2188
2565
  async syncDelete(mutation) {
@@ -2199,7 +2576,7 @@ var SyncEngine = class {
2199
2576
  return false;
2200
2577
  await this.persistence.markDeleted(mutation.collection, mutation.documentId, 0);
2201
2578
  this.store.emitDocument(mutation.collection, mutation.documentId, null, "delete");
2202
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "delete");
2579
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "delete");
2203
2580
  return true;
2204
2581
  }
2205
2582
  scheduleRetry() {
@@ -2219,8 +2596,7 @@ var SyncEngine = class {
2219
2596
  * Returns mutations that exceeded MAX_RETRIES
2220
2597
  */
2221
2598
  async getFailedMutations() {
2222
- const all = await this.persistence.getPendingMutations();
2223
- return all.filter((m) => m.status === "failed");
2599
+ return this.persistence.getFailedMutations();
2224
2600
  }
2225
2601
  /**
2226
2602
  * Retry a specific failed mutation by resetting its retry count
@@ -2262,6 +2638,10 @@ var SyncEngine = class {
2262
2638
  if (this.retryTimeout) {
2263
2639
  clearTimeout(this.retryTimeout);
2264
2640
  }
2641
+ if (typeof window !== "undefined") {
2642
+ window.removeEventListener("online", this.handleOnline);
2643
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
2644
+ }
2265
2645
  }
2266
2646
  };
2267
2647