edmaxlabs-core 2.5.6 → 2.6.7

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);
@@ -1111,16 +1364,18 @@ var Database = class {
1111
1364
  // src/database/Realtime.ts
1112
1365
  var import_socket = require("socket.io-client");
1113
1366
 
1114
- // src/utils/uuid.ts
1115
- function generateUUID() {
1116
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
1117
- return crypto.randomUUID();
1118
- }
1119
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
1120
- const r = Math.random() * 16 | 0;
1121
- const v = c === "x" ? r : r & 3 | 8;
1122
- return v.toString(16);
1123
- });
1367
+ // src/utils/documentNomalizer.ts
1368
+ function normalizePayload(payload) {
1369
+ const raw = payload?.document ?? payload?.data ?? payload;
1370
+ if (!raw)
1371
+ return null;
1372
+ const doc = { ...raw };
1373
+ doc.id = doc.id ?? raw?.id ?? raw?._id ?? payload?.id ?? payload?._id;
1374
+ delete doc._id;
1375
+ return {
1376
+ change: payload?.change ?? raw?.change ?? null,
1377
+ data: doc
1378
+ };
1124
1379
  }
1125
1380
 
1126
1381
  // src/database/Realtime.ts
@@ -1184,15 +1439,19 @@ var Realtime = class {
1184
1439
  */
1185
1440
  subscribeToCollectionRaw(collection, callback, filter = {}) {
1186
1441
  this.connect();
1187
- const lid = `col_${collection}_${generateUUID()}`;
1442
+ const lid = collection;
1188
1443
  const handlers = [];
1189
- this.socket.emit("subscribe", { collection, filter, lid });
1444
+ this.socket.emit("subscribe", { collection, filter });
1190
1445
  this.events.forEach((event) => {
1191
1446
  const channel = `${lid}-${event}`;
1192
1447
  const fn = (payload) => {
1193
1448
  const normalized = normalizePayload(payload);
1194
- if (!normalized)
1449
+ if (!normalized) {
1450
+ if (event === "delete") {
1451
+ callback(payload, "delete");
1452
+ }
1195
1453
  return;
1454
+ }
1196
1455
  callback(normalized.data, event);
1197
1456
  };
1198
1457
  this.socket.on(channel, fn);
@@ -1206,10 +1465,10 @@ var Realtime = class {
1206
1465
  */
1207
1466
  subscribeToDocumentRaw(collection, id, callback) {
1208
1467
  this.connect();
1209
- const lid = `doc_${collection}_${id}_${generateUUID()}`;
1468
+ const lid = collection;
1210
1469
  const filter = { _id: id };
1211
1470
  const handlers = [];
1212
- this.socket.emit("subscribe", { collection, filter, lid });
1471
+ this.socket.emit("subscribe", { collection, filter });
1213
1472
  this.events.forEach((event) => {
1214
1473
  const channel = `${lid}-${event}`;
1215
1474
  const fn = (payload) => {
@@ -1316,11 +1575,11 @@ var LocalStore = class {
1316
1575
  constructor() {
1317
1576
  this.documentListeners = /* @__PURE__ */ new Map();
1318
1577
  this.collectionListeners = /* @__PURE__ */ new Map();
1578
+ this.childListeners = /* @__PURE__ */ new Map();
1319
1579
  }
1320
1580
  docKey(collection, id) {
1321
1581
  return `${collection}:${id}`;
1322
1582
  }
1323
- // ===================== DOCUMENT LISTENERS =====================
1324
1583
  subscribeToDocument(collection, id, callback) {
1325
1584
  const key = this.docKey(collection, id);
1326
1585
  if (!this.documentListeners.has(key)) {
@@ -1335,24 +1594,33 @@ var LocalStore = class {
1335
1594
  }
1336
1595
  };
1337
1596
  }
1338
- // ===================== COLLECTION LISTENERS =====================
1339
- subscribeToCollection(collection, callback) {
1340
- if (!this.collectionListeners.has(collection)) {
1341
- this.collectionListeners.set(collection, /* @__PURE__ */ new Set());
1597
+ subscribeToCollection(targetKey, callback) {
1598
+ if (!this.collectionListeners.has(targetKey)) {
1599
+ this.collectionListeners.set(targetKey, /* @__PURE__ */ new Set());
1342
1600
  }
1343
- const listeners = this.collectionListeners.get(collection);
1601
+ const listeners = this.collectionListeners.get(targetKey);
1344
1602
  listeners.add(callback);
1345
1603
  return () => {
1346
1604
  listeners.delete(callback);
1347
1605
  if (listeners.size === 0) {
1348
- this.collectionListeners.delete(collection);
1606
+ this.collectionListeners.delete(targetKey);
1607
+ this.childListeners.delete(targetKey);
1608
+ }
1609
+ };
1610
+ }
1611
+ subscribeToChildEvents(targetKey, callbacks) {
1612
+ if (!this.childListeners.has(targetKey)) {
1613
+ this.childListeners.set(targetKey, /* @__PURE__ */ new Set());
1614
+ }
1615
+ const listeners = this.childListeners.get(targetKey);
1616
+ listeners.add(callbacks);
1617
+ return () => {
1618
+ listeners.delete(callbacks);
1619
+ if (listeners.size === 0) {
1620
+ this.childListeners.delete(targetKey);
1349
1621
  }
1350
1622
  };
1351
1623
  }
1352
- // ===================== EMITTERS =====================
1353
- /**
1354
- * Notify all listeners for a specific document
1355
- */
1356
1624
  emitDocument(collection, id, snapshot, change) {
1357
1625
  const key = this.docKey(collection, id);
1358
1626
  this.documentListeners.get(key)?.forEach((cb) => {
@@ -1363,64 +1631,66 @@ var LocalStore = class {
1363
1631
  }
1364
1632
  });
1365
1633
  }
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) => {
1634
+ emitCollection(targetKey, emission) {
1635
+ this.collectionListeners.get(targetKey)?.forEach((cb) => {
1372
1636
  try {
1373
- cb(snapshots, change, changedDocId);
1637
+ cb(emission.snapshots, emission.source, emission.changedDocId);
1374
1638
  } catch (err) {
1375
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1639
+ console.error(`[EdmaxLabs] Error in collection listener for ${targetKey}:`, err);
1376
1640
  }
1377
1641
  });
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
- }
1642
+ const childChanges = emission.childChanges ?? [];
1643
+ if (childChanges.length === 0)
1644
+ return;
1645
+ this.childListeners.get(targetKey)?.forEach((callbacks) => {
1646
+ childChanges.forEach((change) => {
1647
+ this.dispatchChildEvent(callbacks, change, emission);
1648
+ });
1391
1649
  });
1392
1650
  }
1393
- // ===================== UTILITY =====================
1394
- /**
1395
- * Clear all listeners (useful for testing or when persistence is disabled)
1396
- */
1397
1651
  clearAllListeners() {
1398
1652
  this.documentListeners.clear();
1399
1653
  this.collectionListeners.clear();
1654
+ this.childListeners.clear();
1400
1655
  }
1401
- /**
1402
- * Get current listener count (for debugging / dev tools)
1403
- */
1404
1656
  get listenerCount() {
1405
1657
  let docCount = 0;
1406
1658
  this.documentListeners.forEach((set) => docCount += set.size);
1407
1659
  let collCount = 0;
1408
1660
  this.collectionListeners.forEach((set) => collCount += set.size);
1661
+ this.childListeners.forEach((set) => collCount += set.size);
1409
1662
  return { documents: docCount, collections: collCount };
1410
1663
  }
1411
- /**
1412
- * Remove all listeners for a specific collection (useful for cleanup)
1413
- */
1414
- removeCollectionListeners(collection) {
1415
- this.collectionListeners.delete(collection);
1664
+ removeCollectionListeners(targetKey) {
1665
+ this.collectionListeners.delete(targetKey);
1666
+ this.childListeners.delete(targetKey);
1416
1667
  }
1417
- /**
1418
- * Remove all listeners for a specific document (useful for cleanup)
1419
- */
1420
1668
  removeDocumentListeners(collection, id) {
1421
1669
  const key = this.docKey(collection, id);
1422
1670
  this.documentListeners.delete(key);
1423
1671
  }
1672
+ dispatchChildEvent(callbacks, change, emission) {
1673
+ const context = {
1674
+ snapshots: emission.snapshots,
1675
+ source: emission.source,
1676
+ changedDocId: emission.changedDocId,
1677
+ fromCache: emission.fromCache ?? true,
1678
+ oldIndex: change.oldIndex,
1679
+ newIndex: change.newIndex,
1680
+ previousDoc: change.previousDoc
1681
+ };
1682
+ try {
1683
+ if (change.type === "added") {
1684
+ callbacks.onChildAdded?.(change.doc, context);
1685
+ } else if (change.type === "modified") {
1686
+ callbacks.onChildUpdated?.(change.doc, context);
1687
+ } else if (change.type === "removed") {
1688
+ callbacks.onChildRemoved?.(change.doc, context);
1689
+ }
1690
+ } catch (err) {
1691
+ console.error("[EdmaxLabs] Error in child listener:", err);
1692
+ }
1693
+ }
1424
1694
  };
1425
1695
 
1426
1696
  // ../../../../node_modules/idb/build/wrap-idb-value.js
@@ -1791,11 +2061,25 @@ var Persistence = class {
1791
2061
  }
1792
2062
  async getPendingMutations() {
1793
2063
  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);
2064
+ const pending = await app.getAllFromIndex("mutations", "by-status", "pending");
2065
+ return pending.sort((a, b) => a.createdAt - b.createdAt);
2066
+ }
2067
+ async getFailedMutations() {
2068
+ const app = await this.getDb();
2069
+ const failed = await app.getAllFromIndex("mutations", "by-status", "failed");
2070
+ return failed.sort((a, b) => a.createdAt - b.createdAt);
2071
+ }
2072
+ async recoverSyncingMutations() {
2073
+ const app = await this.getDb();
2074
+ const syncing = await app.getAllFromIndex("mutations", "by-status", "syncing");
2075
+ for (const mutation of syncing) {
2076
+ await app.put("mutations", {
2077
+ ...mutation,
2078
+ status: "pending",
2079
+ updatedAt: this.now()
2080
+ });
2081
+ }
2082
+ return syncing.length;
1799
2083
  }
1800
2084
  async getMutation(mutationId) {
1801
2085
  const app = await this.getDb();
@@ -1900,6 +2184,27 @@ var Persistence = class {
1900
2184
  }
1901
2185
  return this.markDeleted(collection, id, 0);
1902
2186
  }
2187
+ async reconcileCollectionFromRemote(collection, documents) {
2188
+ const app = await this.getDb();
2189
+ const existing = await app.getAllFromIndex("docs", "by-collection", collection);
2190
+ const incomingIds = /* @__PURE__ */ new Set();
2191
+ for (const data of documents) {
2192
+ const id = data.id ?? data._id;
2193
+ if (!id)
2194
+ continue;
2195
+ incomingIds.add(String(id));
2196
+ await this.applyRemoteDoc(collection, { ...data, id: String(id) });
2197
+ }
2198
+ for (const doc of existing) {
2199
+ if (doc.pending && doc.pending > 0)
2200
+ continue;
2201
+ if (!doc.exists || doc.deleted)
2202
+ continue;
2203
+ if (incomingIds.has(doc.id))
2204
+ continue;
2205
+ await this.markDeleted(collection, doc.id, 0);
2206
+ }
2207
+ }
1903
2208
  // ==================== UTILITIES ====================
1904
2209
  createLocalId() {
1905
2210
  return `local_${crypto.randomUUID()}`;
@@ -1929,112 +2234,169 @@ var RealtimeBridge = class {
1929
2234
  this.app = app;
1930
2235
  this.persistence = persistence;
1931
2236
  this.store = store;
1932
- this.collectionUnsubs = /* @__PURE__ */ new Map();
1933
- this.documentUnsubs = /* @__PURE__ */ new Map();
2237
+ this.collectionTargets = /* @__PURE__ */ new Map();
2238
+ this.documentTargets = /* @__PURE__ */ new Map();
1934
2239
  }
1935
2240
  docKey(collection, id) {
1936
2241
  return `${collection}:${id}`;
1937
2242
  }
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);
2243
+ watchCollection(collection, filter = {}, read) {
2244
+ const targetKey = buildTargetKey(collection, filter);
2245
+ const existing = this.collectionTargets.get(targetKey);
2246
+ if (existing) {
2247
+ existing.refCount += 1;
2248
+ return () => this.releaseCollection(targetKey);
1943
2249
  }
1944
- this.emitCurrentCollection(collection);
1945
2250
  const unsub = this.app.rtdb().subscribeToCollectionRaw(
1946
2251
  collection,
1947
2252
  async (payload, change) => {
1948
2253
  if (change === "delete") {
1949
2254
  const id = payload?.id || payload?._id;
1950
- if (id)
2255
+ if (id) {
1951
2256
  await this.handleRemoteDelete(collection, id);
1952
- } else {
1953
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2257
+ }
2258
+ return;
1954
2259
  }
2260
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1955
2261
  },
1956
2262
  filter
1957
2263
  );
1958
- const fullUnsub = () => {
1959
- unsub();
1960
- this.collectionUnsubs.delete(key);
1961
- };
1962
- this.collectionUnsubs.set(key, fullUnsub);
1963
- return fullUnsub;
2264
+ this.collectionTargets.set(targetKey, {
2265
+ targetKey,
2266
+ collection,
2267
+ filter,
2268
+ read,
2269
+ refCount: 1,
2270
+ unsub,
2271
+ lastSnapshots: []
2272
+ });
2273
+ return () => this.releaseCollection(targetKey);
1964
2274
  }
1965
2275
  watchDocument(collection, id) {
1966
2276
  const key = this.docKey(collection, id);
1967
- if (this.documentUnsubs.has(key)) {
1968
- return this.documentUnsubs.get(key);
2277
+ const existing = this.documentTargets.get(key);
2278
+ if (existing) {
2279
+ existing.refCount += 1;
2280
+ return () => this.releaseDocument(key);
1969
2281
  }
1970
- this.emitCurrentDocument(collection, id);
1971
2282
  const unsub = this.app.rtdb().subscribeToDocumentRaw(
1972
2283
  collection,
1973
2284
  id,
1974
2285
  async (payload, change) => {
1975
2286
  if (change === "delete") {
1976
2287
  await this.handleRemoteDelete(collection, id);
1977
- } else {
1978
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2288
+ return;
1979
2289
  }
2290
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1980
2291
  }
1981
2292
  );
1982
- const fullUnsub = () => {
1983
- unsub();
1984
- this.documentUnsubs.delete(key);
1985
- };
1986
- this.documentUnsubs.set(key, fullUnsub);
1987
- return fullUnsub;
2293
+ this.documentTargets.set(key, {
2294
+ key,
2295
+ collection,
2296
+ id,
2297
+ refCount: 1,
2298
+ unsub
2299
+ });
2300
+ return () => this.releaseDocument(key);
2301
+ }
2302
+ async emitCurrentCollection(collection, filter = {}) {
2303
+ const targetKey = buildTargetKey(collection, filter);
2304
+ const target = this.collectionTargets.get(targetKey);
2305
+ if (!target)
2306
+ return [];
2307
+ return this.refreshCollectionTarget(target, "initial");
2308
+ }
2309
+ primeCollectionTarget(collection, filter, snapshots) {
2310
+ const targetKey = buildTargetKey(collection, filter);
2311
+ const target = this.collectionTargets.get(targetKey);
2312
+ if (!target)
2313
+ return;
2314
+ target.lastSnapshots = snapshots;
1988
2315
  }
1989
- // ===================== INTERNAL HANDLERS =====================
1990
2316
  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");
2317
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2318
+ this.store.emitDocument(collection, id, snapshot, "initial");
2319
+ return snapshot;
1994
2320
  }
1995
- async emitCurrentCollection(collection) {
1996
- const localDocs = await this.persistence.getCollectionSnapshots(collection);
1997
- this.store.emitCollection(collection, localDocs, "insert");
2321
+ async publishLocalChange(collection, id, source) {
2322
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2323
+ this.store.emitDocument(collection, id, snapshot, source);
2324
+ await this.refreshCollectionTargets(collection, source, id);
2325
+ }
2326
+ async onReconnect() {
2327
+ for (const target of this.collectionTargets.values()) {
2328
+ await this.refreshCollectionTarget(target, "initial");
2329
+ }
2330
+ for (const target of this.documentTargets.values()) {
2331
+ await this.emitCurrentDocument(target.collection, target.id);
2332
+ }
2333
+ }
2334
+ dispose() {
2335
+ this.collectionTargets.forEach((target) => target.unsub());
2336
+ this.documentTargets.forEach((target) => target.unsub());
2337
+ this.collectionTargets.clear();
2338
+ this.documentTargets.clear();
1998
2339
  }
1999
- async handleRemoteCreateOrUpdate(collection, raw, change) {
2340
+ async handleRemoteCreateOrUpdate(collection, raw, source) {
2000
2341
  const id = raw.id ?? raw._id;
2001
2342
  if (!id)
2002
2343
  return;
2003
2344
  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);
2345
+ if (saved) {
2346
+ const snap = DocumentSnapshot.fromMap(saved.data);
2347
+ this.store.emitDocument(collection, id, snap, source);
2348
+ }
2349
+ await this.refreshCollectionTargets(collection, source, id, false);
2009
2350
  }
2010
2351
  async handleRemoteDelete(collection, id) {
2011
2352
  await this.persistence.applyRemoteDelete(collection, id);
2012
2353
  this.store.emitDocument(collection, id, null, "delete");
2013
- this.store.notifyCollectionChanged(collection, id, "delete");
2354
+ await this.refreshCollectionTargets(collection, "delete", id, false);
2014
2355
  }
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
- }
2356
+ async getCurrentDocumentSnapshot(collection, id) {
2357
+ const localDoc = await this.persistence.getDoc(collection, id);
2358
+ return localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
2029
2359
  }
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();
2360
+ async refreshCollectionTargets(collection, source, changedDocId, fromCache = true) {
2361
+ const targets = Array.from(this.collectionTargets.values()).filter(
2362
+ (target) => target.collection === collection
2363
+ );
2364
+ for (const target of targets) {
2365
+ await this.refreshCollectionTarget(target, source, changedDocId, fromCache);
2366
+ }
2367
+ }
2368
+ async refreshCollectionTarget(target, source, changedDocId, fromCache = true) {
2369
+ const snapshots = await target.read();
2370
+ const childChanges = diffSnapshots(target.lastSnapshots, snapshots);
2371
+ target.lastSnapshots = snapshots;
2372
+ this.store.emitCollection(target.targetKey, {
2373
+ snapshots,
2374
+ source,
2375
+ changedDocId,
2376
+ fromCache,
2377
+ childChanges
2378
+ });
2379
+ return snapshots;
2380
+ }
2381
+ releaseCollection(targetKey) {
2382
+ const target = this.collectionTargets.get(targetKey);
2383
+ if (!target)
2384
+ return;
2385
+ target.refCount -= 1;
2386
+ if (target.refCount > 0)
2387
+ return;
2388
+ target.unsub();
2389
+ this.collectionTargets.delete(targetKey);
2390
+ }
2391
+ releaseDocument(key) {
2392
+ const target = this.documentTargets.get(key);
2393
+ if (!target)
2394
+ return;
2395
+ target.refCount -= 1;
2396
+ if (target.refCount > 0)
2397
+ return;
2398
+ target.unsub();
2399
+ this.documentTargets.delete(key);
2038
2400
  }
2039
2401
  };
2040
2402
 
@@ -2046,16 +2408,19 @@ var SyncEngine = class {
2046
2408
  this.retryTimeout = null;
2047
2409
  this.MAX_RETRIES = 5;
2048
2410
  this.BASE_RETRY_DELAY = 2e3;
2411
+ this.handleOnline = () => this.onNetworkOnline();
2412
+ this.handleVisibilityChange = () => {
2413
+ if (document.visibilityState === "visible")
2414
+ this.onNetworkOnline();
2415
+ };
2049
2416
  this.app = app;
2050
2417
  this.persistence = persistence;
2051
2418
  this.store = store;
2052
2419
  this.realtimeBridge = realtimeBridge || null;
2420
+ this.persistence.recoverSyncingMutations().catch(console.error);
2053
2421
  if (typeof window !== "undefined") {
2054
- window.addEventListener("online", () => this.onNetworkOnline());
2055
- document.addEventListener("visibilitychange", () => {
2056
- if (document.visibilityState === "visible")
2057
- this.onNetworkOnline();
2058
- });
2422
+ window.addEventListener("online", this.handleOnline);
2423
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
2059
2424
  }
2060
2425
  }
2061
2426
  onNetworkOnline() {
@@ -2072,6 +2437,7 @@ var SyncEngine = class {
2072
2437
  return;
2073
2438
  this.syncing = true;
2074
2439
  try {
2440
+ await this.persistence.recoverSyncingMutations();
2075
2441
  const pending = await this.persistence.getPendingMutations();
2076
2442
  for (const mutation of pending) {
2077
2443
  if (mutation.retryCount >= this.MAX_RETRIES) {
@@ -2148,8 +2514,7 @@ var SyncEngine = class {
2148
2514
  if (replaced) {
2149
2515
  const snap = DocumentSnapshot.fromMap(replaced.data);
2150
2516
  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");
2517
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, newId, "insert");
2153
2518
  }
2154
2519
  return true;
2155
2520
  }
@@ -2182,7 +2547,7 @@ var SyncEngine = class {
2182
2547
  });
2183
2548
  const snap = DocumentSnapshot.fromMap(local.data);
2184
2549
  this.store.emitDocument(mutation.collection, mutation.documentId, snap, "update");
2185
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "update");
2550
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "update");
2186
2551
  return true;
2187
2552
  }
2188
2553
  async syncDelete(mutation) {
@@ -2199,7 +2564,7 @@ var SyncEngine = class {
2199
2564
  return false;
2200
2565
  await this.persistence.markDeleted(mutation.collection, mutation.documentId, 0);
2201
2566
  this.store.emitDocument(mutation.collection, mutation.documentId, null, "delete");
2202
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "delete");
2567
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "delete");
2203
2568
  return true;
2204
2569
  }
2205
2570
  scheduleRetry() {
@@ -2219,8 +2584,7 @@ var SyncEngine = class {
2219
2584
  * Returns mutations that exceeded MAX_RETRIES
2220
2585
  */
2221
2586
  async getFailedMutations() {
2222
- const all = await this.persistence.getPendingMutations();
2223
- return all.filter((m) => m.status === "failed");
2587
+ return this.persistence.getFailedMutations();
2224
2588
  }
2225
2589
  /**
2226
2590
  * Retry a specific failed mutation by resetting its retry count
@@ -2262,6 +2626,10 @@ var SyncEngine = class {
2262
2626
  if (this.retryTimeout) {
2263
2627
  clearTimeout(this.retryTimeout);
2264
2628
  }
2629
+ if (typeof window !== "undefined") {
2630
+ window.removeEventListener("online", this.handleOnline);
2631
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
2632
+ }
2265
2633
  }
2266
2634
  };
2267
2635