edmaxlabs-core 2.5.5 → 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
@@ -332,7 +332,6 @@ var _Authentication = class _Authentication {
332
332
  return;
333
333
  userDocUnsubscribe = userRef.onSnapshot(
334
334
  (snapshot, change) => {
335
- console.log(snapshot);
336
335
  if (change === "delete") {
337
336
  this.saveCredentials(null);
338
337
  onSignOut?.();
@@ -392,20 +391,6 @@ var DocumentSnapshot = class _DocumentSnapshot {
392
391
  }
393
392
  };
394
393
 
395
- // src/utils/documentNomalizer.ts
396
- function normalizePayload(payload) {
397
- const raw = payload?.document ?? payload?.data ?? payload;
398
- if (!raw)
399
- return null;
400
- const doc = { ...raw };
401
- doc.id = payload.id ?? payload._id;
402
- delete doc._id;
403
- return {
404
- change: payload?.change ?? raw?.change ?? null,
405
- data: doc
406
- };
407
- }
408
-
409
394
  // src/database/DocumentRef.ts
410
395
  function validateDocumentData(data, operation) {
411
396
  if (data === null || data === void 0) {
@@ -431,40 +416,22 @@ var DocumentRef = class {
431
416
  this.syncEngine = app.offline().syncEngine;
432
417
  this.localStore = app.offline().localStore;
433
418
  }
434
- // ====================== GET ======================
435
419
  async get() {
436
420
  if (this.persistence) {
437
421
  try {
438
422
  const localSnap = await this.persistence.getDocSnapshot(this.collection, this.id);
423
+ if (typeof navigator === "undefined" || navigator.onLine) {
424
+ this.refreshFromRemote().catch(() => {
425
+ });
426
+ }
439
427
  if (localSnap)
440
428
  return localSnap;
441
429
  } catch (error) {
442
430
  console.error("[EdmaxLabs] Cache read error:", error);
443
431
  }
444
432
  }
445
- try {
446
- const res = await new HttpsRequest({
447
- method: "POST" /* POST */,
448
- endpoint: `${this.app.getBaseUrl()}/db/read`,
449
- headers: {
450
- authorization: this.app.getConfig().token,
451
- "x-project": this.app.getConfig().project
452
- },
453
- body: {
454
- collection: this.collection,
455
- id: this.id,
456
- single: true
457
- }
458
- }).sendRequest();
459
- if (!res?.success || !res.document)
460
- return null;
461
- return DocumentSnapshot.fromMap(res.document);
462
- } catch (error) {
463
- console.error(`[DocumentRef] get(${this.collection}/${this.id}) failed:`, error);
464
- return null;
465
- }
433
+ return this.fetchRemoteSnapshot();
466
434
  }
467
- // ====================== UPDATE ======================
468
435
  async update(data) {
469
436
  if (this._isUpdating) {
470
437
  console.warn(`[DocumentRef] update recursion blocked on ${this.collection}/${this.id}`);
@@ -510,15 +477,13 @@ var DocumentRef = class {
510
477
  baseRevision: old.revision
511
478
  });
512
479
  const snap = DocumentSnapshot.fromMap(updated.data);
513
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
514
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "update");
480
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "update").catch(console.error);
515
481
  this.syncEngine?.flush().catch(console.error);
516
482
  return snap;
517
483
  } finally {
518
484
  this._isUpdating = false;
519
485
  }
520
486
  }
521
- // ====================== SET ======================
522
487
  async set(data) {
523
488
  validateDocumentData(data, "DocumentRef.set");
524
489
  if (!this.persistence) {
@@ -533,6 +498,8 @@ var DocumentRef = class {
533
498
  }).sendRequest();
534
499
  return res?.success ? DocumentSnapshot.fromMap({ ...data, id: this.id }) : null;
535
500
  }
501
+ const existing = await this.persistence.getDoc(this.collection, this.id);
502
+ const mutationType = existing?.exists && !existing.deleted ? "update" : "insert";
536
503
  const updated = await this.persistence.upsertDoc({
537
504
  collection: this.collection,
538
505
  id: this.id,
@@ -541,22 +508,27 @@ var DocumentRef = class {
541
508
  deleted: false,
542
509
  pending: 1,
543
510
  localOnly: false,
544
- status: "pending"
511
+ status: "pending",
512
+ revision: existing?.revision,
513
+ lastSyncedAt: existing?.lastSyncedAt
545
514
  });
546
515
  await this.persistence.enqueueMutation({
547
516
  mutationId: this.persistence.createMutationId(),
548
517
  collection: this.collection,
549
518
  documentId: this.id,
550
- type: "insert",
551
- payload: data
519
+ type: mutationType,
520
+ payload: data,
521
+ baseRevision: existing?.revision
552
522
  });
553
523
  const snap = DocumentSnapshot.fromMap(updated.data);
554
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
555
- 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);
556
529
  this.syncEngine?.flush().catch(console.error);
557
530
  return snap;
558
531
  }
559
- // ====================== DELETE ======================
560
532
  async delete() {
561
533
  if (this.persistence) {
562
534
  await this.persistence.markDeleted(this.collection, this.id, 1);
@@ -567,8 +539,7 @@ var DocumentRef = class {
567
539
  type: "delete",
568
540
  payload: null
569
541
  });
570
- this.localStore?.emitDocument(this.collection, this.id, null, "empty");
571
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "delete");
542
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "delete").catch(console.error);
572
543
  this.syncEngine?.flush().catch(console.error);
573
544
  return true;
574
545
  }
@@ -583,20 +554,135 @@ var DocumentRef = class {
583
554
  }).sendRequest();
584
555
  return !!res?.success;
585
556
  }
586
- // ====================== SNAPSHOT ======================
587
557
  onSnapshot(callback) {
588
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
589
- this.app.offline().realtimeBridge?.watchDocument(this.collection, this.id);
590
- return this.localStore.subscribeToDocument(this.collection, this.id, callback) || (() => {
591
- });
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
+ };
592
567
  }
593
- return this.app.rtdb().subscribeToDocumentRaw(this.collection, this.id, (snapshot, change) => {
594
- const res = normalizePayload(snapshot);
595
- 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);
596
575
  });
597
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
+ }
598
611
  };
599
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
+
600
686
  // src/database/Query.ts
601
687
  var Query = class {
602
688
  constructor(app, collection) {
@@ -609,6 +695,84 @@ var Query = class {
609
695
  this.filter.push(expression);
610
696
  return this;
611
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
+ }
612
776
  buildFilter() {
613
777
  const mongoFilter = {};
614
778
  this.filter.forEach(({ key, op, value }) => {
@@ -679,9 +843,7 @@ var Query = class {
679
843
  if (this.filter.length === 0)
680
844
  return docs;
681
845
  return docs.filter(
682
- (snapshot) => this.filter.every(
683
- (expression) => this.matchesFilter(snapshot.data, expression)
684
- )
846
+ (snapshot) => this.filter.every((expression) => this.matchesFilter(snapshot.data, expression))
685
847
  );
686
848
  }
687
849
  async getLocalFilteredSnapshots() {
@@ -691,25 +853,6 @@ var Query = class {
691
853
  const docs = await persistence.getCollectionSnapshots(this.collection);
692
854
  return this.filterDocuments(docs);
693
855
  }
694
- async get() {
695
- const persistence = this.app.offline().persistence;
696
- if (this.filter.length > 0 && persistence) {
697
- const local = await this.getLocalFilteredSnapshots();
698
- if (typeof navigator === "undefined" || !navigator.onLine) {
699
- return local;
700
- }
701
- return this.refreshFromRemote();
702
- }
703
- if (persistence) {
704
- const local = await persistence.getCollectionSnapshots(this.collection);
705
- if (typeof navigator === "undefined" || navigator.onLine) {
706
- this.refreshFromRemote().catch(() => {
707
- });
708
- }
709
- return local;
710
- }
711
- return this.refreshFromRemote();
712
- }
713
856
  async refreshFromRemote() {
714
857
  try {
715
858
  const genfilter = this.buildFilter();
@@ -725,69 +868,90 @@ var Query = class {
725
868
  if (!res?.success || !Array.isArray(res.documents)) {
726
869
  return [];
727
870
  }
728
- return res.documents.map((d) => {
871
+ const snapshots = res.documents.map((d) => {
729
872
  delete d.token;
730
873
  d.id = d.id ?? d._id;
731
874
  delete d._id;
732
875
  return DocumentSnapshot.fromMap(d);
733
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();
734
885
  } catch {
735
886
  return [];
736
887
  }
737
888
  }
738
- // Advanced: query-wide update (keep for now, but document it's server-side only)
739
- async update(data) {
740
- const genfilter = this.buildFilter();
741
- const res = await new HttpsRequest({
742
- method: "POST" /* POST */,
743
- endpoint: `${this.app.getBaseUrl()}/db/update`,
744
- headers: { authorization: this.app.getConfig().token, "x-project": this.app.getConfig().project },
745
- body: {
746
- collection: this.collection,
747
- filter: genfilter,
748
- data
749
- }
750
- }).sendRequest();
751
- 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
+ );
752
906
  }
753
- onSnapshot(callback) {
754
- const genfilter = this.buildFilter();
755
- const persistence = this.app.offline().persistence;
756
- if (persistence) {
757
- const remoteUnsub = this.app.offline().realtimeBridge?.watchCollection(this.collection, genfilter);
758
- if (this.filter.length > 0) {
759
- const emitFiltered = async (change, changedDocId) => {
760
- const docs = await this.getLocalFilteredSnapshots();
761
- callback(docs, change, changedDocId);
762
- };
763
- emitFiltered("insert").catch(console.error);
764
- const localUnsub2 = this.localStore?.subscribeToCollection(
765
- this.collection,
766
- async (snapshots, change, changedDocId) => {
767
- if (snapshots.length > 0) {
768
- callback(this.filterDocuments(snapshots), change, changedDocId);
769
- } else {
770
- await emitFiltered(change, changedDocId);
771
- }
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);
772
937
  }
773
- ) ?? (() => {
774
938
  });
775
- return () => {
776
- localUnsub2();
777
- remoteUnsub?.();
778
- };
779
- }
780
- 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
781
953
  });
782
- return () => {
783
- localUnsub();
784
- remoteUnsub?.();
785
- };
786
- }
787
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
788
- const res = normalizePayload(payload);
789
- callback([DocumentSnapshot.fromMap(res?.data)], changes);
790
- }, genfilter);
954
+ });
791
955
  }
792
956
  };
793
957
 
@@ -819,6 +983,89 @@ var CollectionRef = class {
819
983
  }
820
984
  return local;
821
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() {
822
1069
  try {
823
1070
  const res = await new HttpsRequest({
824
1071
  method: "POST" /* POST */,
@@ -835,18 +1082,25 @@ var CollectionRef = class {
835
1082
  if (!res?.success || !Array.isArray(res.documents)) {
836
1083
  return [];
837
1084
  }
838
- return res.documents.map((d) => {
839
- delete d.token;
840
- d.id = d.id ?? d._id;
841
- delete d._id;
842
- 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;
843
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));
844
1098
  } catch (error) {
845
- console.error("[EdmaxLabs] Collection get failed:", error);
1099
+ console.error("[EdmaxLabs] refreshFromRemote failed:", error);
846
1100
  return [];
847
1101
  }
848
1102
  }
849
- async refreshFromRemote() {
1103
+ async fetchRemoteSnapshots() {
850
1104
  try {
851
1105
  const res = await new HttpsRequest({
852
1106
  method: "POST" /* POST */,
@@ -863,17 +1117,6 @@ var CollectionRef = class {
863
1117
  if (!res?.success || !Array.isArray(res.documents)) {
864
1118
  return [];
865
1119
  }
866
- for (const raw of res.documents) {
867
- const doc = { ...raw };
868
- doc.id = doc.id ?? doc._id;
869
- delete doc._id;
870
- if (this.persistence) {
871
- await this.persistence.applyRemoteDoc(this.collection, doc);
872
- }
873
- }
874
- if (this.persistence) {
875
- return await this.persistence.getCollectionSnapshots(this.collection);
876
- }
877
1120
  return res.documents.map((d) => {
878
1121
  delete d.token;
879
1122
  d.id = d.id ?? d._id;
@@ -881,59 +1124,68 @@ var CollectionRef = class {
881
1124
  return DocumentSnapshot.fromMap(d);
882
1125
  });
883
1126
  } catch (error) {
884
- console.error("[EdmaxLabs] refreshFromRemote failed:", error);
1127
+ console.error("[EdmaxLabs] Collection get failed:", error);
885
1128
  return [];
886
1129
  }
887
1130
  }
888
- async add(data) {
889
- if (this.persistence) {
890
- const localId = this.persistence.createLocalId();
891
- const docRecord = await this.persistence.upsertDoc({
892
- collection: this.collection,
893
- id: localId,
894
- data: { ...data, id: localId },
895
- exists: true,
896
- deleted: false,
897
- pending: 1,
898
- localOnly: true,
899
- status: "pending"
900
- });
901
- await this.persistence.enqueueMutation({
902
- mutationId: this.persistence.createMutationId(),
903
- collection: this.collection,
904
- documentId: localId,
905
- type: "insert",
906
- payload: docRecord.data
907
- });
908
- const snap = DocumentSnapshot.fromMap(docRecord.data);
909
- this.localStore?.emitDocument(this.collection, localId, snap, "insert");
910
- this.localStore?.notifyCollectionChanged(this.collection, localId, "insert");
911
- this.syncEngine?.flush().catch(console.error);
912
- return snap;
913
- }
914
- const res = await new HttpsRequest({
915
- method: "POST" /* POST */,
916
- endpoint: `${this.app.getBaseUrl()}/db/create`,
917
- headers: {
918
- authorization: this.app.getConfig().token,
919
- "x-project": this.app.getConfig().project
920
- },
921
- body: { collection: this.collection, data: { ...data, id: "" } }
922
- // server will generate id
923
- }).sendRequest();
924
- if (!res?.success || !res.document)
925
- return null;
926
- 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
+ });
927
1143
  }
928
- onSnapshot(callback) {
929
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
930
- this.app.offline().realtimeBridge?.watchCollection(this.collection);
931
- 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
+ }
932
1173
  });
933
- }
934
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
935
- const res = normalizePayload(payload);
936
- 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);
937
1189
  });
938
1190
  }
939
1191
  };
@@ -983,6 +1235,7 @@ var Batch = class {
983
1235
  const persistence = this.app.offline().persistence;
984
1236
  const localStore = this.app.offline().localStore;
985
1237
  const syncEngine = this.app.offline().syncEngine;
1238
+ const realtimeBridge = this.app.offline().realtimeBridge;
986
1239
  if (persistence && localStore) {
987
1240
  const results = [];
988
1241
  for (const op of this.ops) {
@@ -1011,8 +1264,7 @@ var Batch = class {
1011
1264
  payload: op.data
1012
1265
  });
1013
1266
  const snap = DocumentSnapshot.fromMap(upserted.data);
1014
- localStore.emitDocument(op.collection, op.id, snap, "insert");
1015
- localStore.notifyCollectionChanged(op.collection, op.id, "insert");
1267
+ realtimeBridge?.publishLocalChange(op.collection, op.id, "insert").catch(console.error);
1016
1268
  results.push(snap);
1017
1269
  } else if (op.op === "delete") {
1018
1270
  const docRef = new DocumentRef(this.app, op.collection, op.id);
@@ -1124,6 +1376,20 @@ function generateUUID() {
1124
1376
  });
1125
1377
  }
1126
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
+
1127
1393
  // src/database/Realtime.ts
1128
1394
  var Realtime = class {
1129
1395
  constructor(app) {
@@ -1192,8 +1458,12 @@ var Realtime = class {
1192
1458
  const channel = `${lid}-${event}`;
1193
1459
  const fn = (payload) => {
1194
1460
  const normalized = normalizePayload(payload);
1195
- if (!normalized)
1461
+ if (!normalized) {
1462
+ if (event === "delete") {
1463
+ callback(payload, "delete");
1464
+ }
1196
1465
  return;
1466
+ }
1197
1467
  callback(normalized.data, event);
1198
1468
  };
1199
1469
  this.socket.on(channel, fn);
@@ -1317,11 +1587,11 @@ var LocalStore = class {
1317
1587
  constructor() {
1318
1588
  this.documentListeners = /* @__PURE__ */ new Map();
1319
1589
  this.collectionListeners = /* @__PURE__ */ new Map();
1590
+ this.childListeners = /* @__PURE__ */ new Map();
1320
1591
  }
1321
1592
  docKey(collection, id) {
1322
1593
  return `${collection}:${id}`;
1323
1594
  }
1324
- // ===================== DOCUMENT LISTENERS =====================
1325
1595
  subscribeToDocument(collection, id, callback) {
1326
1596
  const key = this.docKey(collection, id);
1327
1597
  if (!this.documentListeners.has(key)) {
@@ -1336,24 +1606,33 @@ var LocalStore = class {
1336
1606
  }
1337
1607
  };
1338
1608
  }
1339
- // ===================== COLLECTION LISTENERS =====================
1340
- subscribeToCollection(collection, callback) {
1341
- if (!this.collectionListeners.has(collection)) {
1342
- 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());
1343
1612
  }
1344
- const listeners = this.collectionListeners.get(collection);
1613
+ const listeners = this.collectionListeners.get(targetKey);
1345
1614
  listeners.add(callback);
1346
1615
  return () => {
1347
1616
  listeners.delete(callback);
1348
1617
  if (listeners.size === 0) {
1349
- 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);
1350
1633
  }
1351
1634
  };
1352
1635
  }
1353
- // ===================== EMITTERS =====================
1354
- /**
1355
- * Notify all listeners for a specific document
1356
- */
1357
1636
  emitDocument(collection, id, snapshot, change) {
1358
1637
  const key = this.docKey(collection, id);
1359
1638
  this.documentListeners.get(key)?.forEach((cb) => {
@@ -1364,64 +1643,66 @@ var LocalStore = class {
1364
1643
  }
1365
1644
  });
1366
1645
  }
1367
- /**
1368
- * Notify all listeners for a collection.
1369
- * This is the most important fix — collection listeners now receive the full list.
1370
- */
1371
- emitCollection(collection, snapshots, change, changedDocId) {
1372
- this.collectionListeners.get(collection)?.forEach((cb) => {
1646
+ emitCollection(targetKey, emission) {
1647
+ this.collectionListeners.get(targetKey)?.forEach((cb) => {
1373
1648
  try {
1374
- cb(snapshots, change, changedDocId);
1649
+ cb(emission.snapshots, emission.source, emission.changedDocId);
1375
1650
  } catch (err) {
1376
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1651
+ console.error(`[EdmaxLabs] Error in collection listener for ${targetKey}:`, err);
1377
1652
  }
1378
1653
  });
1379
- }
1380
- /**
1381
- * OPTIMIZED: Notify collection listeners that something changed, without fetching full collection.
1382
- * Listeners can call getCollectionSnapshots() themselves if they need the full list.
1383
- * This avoids expensive collection queries after every single mutation.
1384
- */
1385
- notifyCollectionChanged(collection, changedDocId, change) {
1386
- this.collectionListeners.get(collection)?.forEach((cb) => {
1387
- try {
1388
- cb([], change, changedDocId);
1389
- } catch (err) {
1390
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1391
- }
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
+ });
1392
1661
  });
1393
1662
  }
1394
- // ===================== UTILITY =====================
1395
- /**
1396
- * Clear all listeners (useful for testing or when persistence is disabled)
1397
- */
1398
1663
  clearAllListeners() {
1399
1664
  this.documentListeners.clear();
1400
1665
  this.collectionListeners.clear();
1666
+ this.childListeners.clear();
1401
1667
  }
1402
- /**
1403
- * Get current listener count (for debugging / dev tools)
1404
- */
1405
1668
  get listenerCount() {
1406
1669
  let docCount = 0;
1407
1670
  this.documentListeners.forEach((set) => docCount += set.size);
1408
1671
  let collCount = 0;
1409
1672
  this.collectionListeners.forEach((set) => collCount += set.size);
1673
+ this.childListeners.forEach((set) => collCount += set.size);
1410
1674
  return { documents: docCount, collections: collCount };
1411
1675
  }
1412
- /**
1413
- * Remove all listeners for a specific collection (useful for cleanup)
1414
- */
1415
- removeCollectionListeners(collection) {
1416
- this.collectionListeners.delete(collection);
1676
+ removeCollectionListeners(targetKey) {
1677
+ this.collectionListeners.delete(targetKey);
1678
+ this.childListeners.delete(targetKey);
1417
1679
  }
1418
- /**
1419
- * Remove all listeners for a specific document (useful for cleanup)
1420
- */
1421
1680
  removeDocumentListeners(collection, id) {
1422
1681
  const key = this.docKey(collection, id);
1423
1682
  this.documentListeners.delete(key);
1424
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
+ }
1425
1706
  };
1426
1707
 
1427
1708
  // ../../../../node_modules/idb/build/wrap-idb-value.js
@@ -1792,11 +2073,25 @@ var Persistence = class {
1792
2073
  }
1793
2074
  async getPendingMutations() {
1794
2075
  const app = await this.getDb();
1795
- const [pending, failed] = await Promise.all([
1796
- app.getAllFromIndex("mutations", "by-status", "pending"),
1797
- app.getAllFromIndex("mutations", "by-status", "failed")
1798
- ]);
1799
- 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;
1800
2095
  }
1801
2096
  async getMutation(mutationId) {
1802
2097
  const app = await this.getDb();
@@ -1901,6 +2196,27 @@ var Persistence = class {
1901
2196
  }
1902
2197
  return this.markDeleted(collection, id, 0);
1903
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
+ }
1904
2220
  // ==================== UTILITIES ====================
1905
2221
  createLocalId() {
1906
2222
  return `local_${crypto.randomUUID()}`;
@@ -1930,112 +2246,169 @@ var RealtimeBridge = class {
1930
2246
  this.app = app;
1931
2247
  this.persistence = persistence;
1932
2248
  this.store = store;
1933
- this.collectionUnsubs = /* @__PURE__ */ new Map();
1934
- this.documentUnsubs = /* @__PURE__ */ new Map();
2249
+ this.collectionTargets = /* @__PURE__ */ new Map();
2250
+ this.documentTargets = /* @__PURE__ */ new Map();
1935
2251
  }
1936
2252
  docKey(collection, id) {
1937
2253
  return `${collection}:${id}`;
1938
2254
  }
1939
- // ===================== PUBLIC API =====================
1940
- watchCollection(collection, filter = {}) {
1941
- const key = `${collection}:${JSON.stringify(filter)}`;
1942
- if (this.collectionUnsubs.has(key)) {
1943
- 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);
1944
2261
  }
1945
- this.emitCurrentCollection(collection);
1946
2262
  const unsub = this.app.rtdb().subscribeToCollectionRaw(
1947
2263
  collection,
1948
2264
  async (payload, change) => {
1949
2265
  if (change === "delete") {
1950
2266
  const id = payload?.id || payload?._id;
1951
- if (id)
2267
+ if (id) {
1952
2268
  await this.handleRemoteDelete(collection, id);
1953
- } else {
1954
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2269
+ }
2270
+ return;
1955
2271
  }
2272
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1956
2273
  },
1957
2274
  filter
1958
2275
  );
1959
- const fullUnsub = () => {
1960
- unsub();
1961
- this.collectionUnsubs.delete(key);
1962
- };
1963
- this.collectionUnsubs.set(key, fullUnsub);
1964
- 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);
1965
2286
  }
1966
2287
  watchDocument(collection, id) {
1967
2288
  const key = this.docKey(collection, id);
1968
- if (this.documentUnsubs.has(key)) {
1969
- return this.documentUnsubs.get(key);
2289
+ const existing = this.documentTargets.get(key);
2290
+ if (existing) {
2291
+ existing.refCount += 1;
2292
+ return () => this.releaseDocument(key);
1970
2293
  }
1971
- this.emitCurrentDocument(collection, id);
1972
2294
  const unsub = this.app.rtdb().subscribeToDocumentRaw(
1973
2295
  collection,
1974
2296
  id,
1975
2297
  async (payload, change) => {
1976
2298
  if (change === "delete") {
1977
2299
  await this.handleRemoteDelete(collection, id);
1978
- } else {
1979
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2300
+ return;
1980
2301
  }
2302
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1981
2303
  }
1982
2304
  );
1983
- const fullUnsub = () => {
1984
- unsub();
1985
- this.documentUnsubs.delete(key);
1986
- };
1987
- this.documentUnsubs.set(key, fullUnsub);
1988
- 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;
1989
2327
  }
1990
- // ===================== INTERNAL HANDLERS =====================
1991
2328
  async emitCurrentDocument(collection, id) {
1992
- const localDoc = await this.persistence.getDoc(collection, id);
1993
- const snapshot = localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
1994
- 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;
1995
2332
  }
1996
- async emitCurrentCollection(collection) {
1997
- const localDocs = await this.persistence.getCollectionSnapshots(collection);
1998
- 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();
1999
2351
  }
2000
- async handleRemoteCreateOrUpdate(collection, raw, change) {
2352
+ async handleRemoteCreateOrUpdate(collection, raw, source) {
2001
2353
  const id = raw.id ?? raw._id;
2002
2354
  if (!id)
2003
2355
  return;
2004
2356
  const saved = await this.persistence.applyRemoteDoc(collection, raw);
2005
- if (!saved)
2006
- return;
2007
- const snap = DocumentSnapshot.fromMap(saved.data);
2008
- this.store.emitDocument(collection, id, snap, change);
2009
- 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);
2010
2362
  }
2011
2363
  async handleRemoteDelete(collection, id) {
2012
2364
  await this.persistence.applyRemoteDelete(collection, id);
2013
2365
  this.store.emitDocument(collection, id, null, "delete");
2014
- this.store.notifyCollectionChanged(collection, id, "delete");
2366
+ await this.refreshCollectionTargets(collection, "delete", id, false);
2015
2367
  }
2016
- // ===================== LIFECYCLE =====================
2017
- /**
2018
- * Call this when Socket.IO reconnects or when going online
2019
- * Replays pending mutations + refreshes all active subscriptions from cache
2020
- */
2021
- async onReconnect() {
2022
- for (const [key] of this.collectionUnsubs) {
2023
- const collection = key.split(":")[0];
2024
- await this.emitCurrentCollection(collection);
2025
- }
2026
- for (const [key] of this.documentUnsubs) {
2027
- const [collection, id] = key.split(":");
2028
- await this.emitCurrentDocument(collection, id);
2029
- }
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;
2030
2371
  }
2031
- /**
2032
- * Clean up all subscriptions (call from EdmaxLabs destructor if needed)
2033
- */
2034
- dispose() {
2035
- this.collectionUnsubs.forEach((unsub) => unsub());
2036
- this.documentUnsubs.forEach((unsub) => unsub());
2037
- this.collectionUnsubs.clear();
2038
- 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);
2039
2412
  }
2040
2413
  };
2041
2414
 
@@ -2047,16 +2420,19 @@ var SyncEngine = class {
2047
2420
  this.retryTimeout = null;
2048
2421
  this.MAX_RETRIES = 5;
2049
2422
  this.BASE_RETRY_DELAY = 2e3;
2423
+ this.handleOnline = () => this.onNetworkOnline();
2424
+ this.handleVisibilityChange = () => {
2425
+ if (document.visibilityState === "visible")
2426
+ this.onNetworkOnline();
2427
+ };
2050
2428
  this.app = app;
2051
2429
  this.persistence = persistence;
2052
2430
  this.store = store;
2053
2431
  this.realtimeBridge = realtimeBridge || null;
2432
+ this.persistence.recoverSyncingMutations().catch(console.error);
2054
2433
  if (typeof window !== "undefined") {
2055
- window.addEventListener("online", () => this.onNetworkOnline());
2056
- document.addEventListener("visibilitychange", () => {
2057
- if (document.visibilityState === "visible")
2058
- this.onNetworkOnline();
2059
- });
2434
+ window.addEventListener("online", this.handleOnline);
2435
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
2060
2436
  }
2061
2437
  }
2062
2438
  onNetworkOnline() {
@@ -2073,6 +2449,7 @@ var SyncEngine = class {
2073
2449
  return;
2074
2450
  this.syncing = true;
2075
2451
  try {
2452
+ await this.persistence.recoverSyncingMutations();
2076
2453
  const pending = await this.persistence.getPendingMutations();
2077
2454
  for (const mutation of pending) {
2078
2455
  if (mutation.retryCount >= this.MAX_RETRIES) {
@@ -2149,8 +2526,7 @@ var SyncEngine = class {
2149
2526
  if (replaced) {
2150
2527
  const snap = DocumentSnapshot.fromMap(replaced.data);
2151
2528
  this.store.emitDocument(mutation.collection, oldId, snap, "insert");
2152
- this.store.emitDocument(mutation.collection, newId, snap, "insert");
2153
- this.store.notifyCollectionChanged(mutation.collection, newId, "insert");
2529
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, newId, "insert");
2154
2530
  }
2155
2531
  return true;
2156
2532
  }
@@ -2183,7 +2559,7 @@ var SyncEngine = class {
2183
2559
  });
2184
2560
  const snap = DocumentSnapshot.fromMap(local.data);
2185
2561
  this.store.emitDocument(mutation.collection, mutation.documentId, snap, "update");
2186
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "update");
2562
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "update");
2187
2563
  return true;
2188
2564
  }
2189
2565
  async syncDelete(mutation) {
@@ -2200,7 +2576,7 @@ var SyncEngine = class {
2200
2576
  return false;
2201
2577
  await this.persistence.markDeleted(mutation.collection, mutation.documentId, 0);
2202
2578
  this.store.emitDocument(mutation.collection, mutation.documentId, null, "delete");
2203
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "delete");
2579
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "delete");
2204
2580
  return true;
2205
2581
  }
2206
2582
  scheduleRetry() {
@@ -2220,8 +2596,7 @@ var SyncEngine = class {
2220
2596
  * Returns mutations that exceeded MAX_RETRIES
2221
2597
  */
2222
2598
  async getFailedMutations() {
2223
- const all = await this.persistence.getPendingMutations();
2224
- return all.filter((m) => m.status === "failed");
2599
+ return this.persistence.getFailedMutations();
2225
2600
  }
2226
2601
  /**
2227
2602
  * Retry a specific failed mutation by resetting its retry count
@@ -2263,6 +2638,10 @@ var SyncEngine = class {
2263
2638
  if (this.retryTimeout) {
2264
2639
  clearTimeout(this.retryTimeout);
2265
2640
  }
2641
+ if (typeof window !== "undefined") {
2642
+ window.removeEventListener("online", this.handleOnline);
2643
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
2644
+ }
2266
2645
  }
2267
2646
  };
2268
2647