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.mjs CHANGED
@@ -300,7 +300,6 @@ var _Authentication = class _Authentication {
300
300
  return;
301
301
  userDocUnsubscribe = userRef.onSnapshot(
302
302
  (snapshot, change) => {
303
- console.log(snapshot);
304
303
  if (change === "delete") {
305
304
  this.saveCredentials(null);
306
305
  onSignOut?.();
@@ -360,20 +359,6 @@ var DocumentSnapshot = class _DocumentSnapshot {
360
359
  }
361
360
  };
362
361
 
363
- // src/utils/documentNomalizer.ts
364
- function normalizePayload(payload) {
365
- const raw = payload?.document ?? payload?.data ?? payload;
366
- if (!raw)
367
- return null;
368
- const doc = { ...raw };
369
- doc.id = payload.id ?? payload._id;
370
- delete doc._id;
371
- return {
372
- change: payload?.change ?? raw?.change ?? null,
373
- data: doc
374
- };
375
- }
376
-
377
362
  // src/database/DocumentRef.ts
378
363
  function validateDocumentData(data, operation) {
379
364
  if (data === null || data === void 0) {
@@ -399,40 +384,22 @@ var DocumentRef = class {
399
384
  this.syncEngine = app.offline().syncEngine;
400
385
  this.localStore = app.offline().localStore;
401
386
  }
402
- // ====================== GET ======================
403
387
  async get() {
404
388
  if (this.persistence) {
405
389
  try {
406
390
  const localSnap = await this.persistence.getDocSnapshot(this.collection, this.id);
391
+ if (typeof navigator === "undefined" || navigator.onLine) {
392
+ this.refreshFromRemote().catch(() => {
393
+ });
394
+ }
407
395
  if (localSnap)
408
396
  return localSnap;
409
397
  } catch (error) {
410
398
  console.error("[EdmaxLabs] Cache read error:", error);
411
399
  }
412
400
  }
413
- try {
414
- const res = await new HttpsRequest({
415
- method: "POST" /* POST */,
416
- endpoint: `${this.app.getBaseUrl()}/db/read`,
417
- headers: {
418
- authorization: this.app.getConfig().token,
419
- "x-project": this.app.getConfig().project
420
- },
421
- body: {
422
- collection: this.collection,
423
- id: this.id,
424
- single: true
425
- }
426
- }).sendRequest();
427
- if (!res?.success || !res.document)
428
- return null;
429
- return DocumentSnapshot.fromMap(res.document);
430
- } catch (error) {
431
- console.error(`[DocumentRef] get(${this.collection}/${this.id}) failed:`, error);
432
- return null;
433
- }
401
+ return this.fetchRemoteSnapshot();
434
402
  }
435
- // ====================== UPDATE ======================
436
403
  async update(data) {
437
404
  if (this._isUpdating) {
438
405
  console.warn(`[DocumentRef] update recursion blocked on ${this.collection}/${this.id}`);
@@ -478,15 +445,13 @@ var DocumentRef = class {
478
445
  baseRevision: old.revision
479
446
  });
480
447
  const snap = DocumentSnapshot.fromMap(updated.data);
481
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
482
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "update");
448
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "update").catch(console.error);
483
449
  this.syncEngine?.flush().catch(console.error);
484
450
  return snap;
485
451
  } finally {
486
452
  this._isUpdating = false;
487
453
  }
488
454
  }
489
- // ====================== SET ======================
490
455
  async set(data) {
491
456
  validateDocumentData(data, "DocumentRef.set");
492
457
  if (!this.persistence) {
@@ -501,6 +466,8 @@ var DocumentRef = class {
501
466
  }).sendRequest();
502
467
  return res?.success ? DocumentSnapshot.fromMap({ ...data, id: this.id }) : null;
503
468
  }
469
+ const existing = await this.persistence.getDoc(this.collection, this.id);
470
+ const mutationType = existing?.exists && !existing.deleted ? "update" : "insert";
504
471
  const updated = await this.persistence.upsertDoc({
505
472
  collection: this.collection,
506
473
  id: this.id,
@@ -509,22 +476,27 @@ var DocumentRef = class {
509
476
  deleted: false,
510
477
  pending: 1,
511
478
  localOnly: false,
512
- status: "pending"
479
+ status: "pending",
480
+ revision: existing?.revision,
481
+ lastSyncedAt: existing?.lastSyncedAt
513
482
  });
514
483
  await this.persistence.enqueueMutation({
515
484
  mutationId: this.persistence.createMutationId(),
516
485
  collection: this.collection,
517
486
  documentId: this.id,
518
- type: "insert",
519
- payload: data
487
+ type: mutationType,
488
+ payload: data,
489
+ baseRevision: existing?.revision
520
490
  });
521
491
  const snap = DocumentSnapshot.fromMap(updated.data);
522
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
523
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "update");
492
+ this.app.offline().realtimeBridge?.publishLocalChange(
493
+ this.collection,
494
+ this.id,
495
+ mutationType === "insert" ? "insert" : "update"
496
+ ).catch(console.error);
524
497
  this.syncEngine?.flush().catch(console.error);
525
498
  return snap;
526
499
  }
527
- // ====================== DELETE ======================
528
500
  async delete() {
529
501
  if (this.persistence) {
530
502
  await this.persistence.markDeleted(this.collection, this.id, 1);
@@ -535,8 +507,7 @@ var DocumentRef = class {
535
507
  type: "delete",
536
508
  payload: null
537
509
  });
538
- this.localStore?.emitDocument(this.collection, this.id, null, "empty");
539
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "delete");
510
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "delete").catch(console.error);
540
511
  this.syncEngine?.flush().catch(console.error);
541
512
  return true;
542
513
  }
@@ -551,20 +522,135 @@ var DocumentRef = class {
551
522
  }).sendRequest();
552
523
  return !!res?.success;
553
524
  }
554
- // ====================== SNAPSHOT ======================
555
525
  onSnapshot(callback) {
556
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
557
- this.app.offline().realtimeBridge?.watchDocument(this.collection, this.id);
558
- return this.localStore.subscribeToDocument(this.collection, this.id, callback) || (() => {
559
- });
526
+ const realtimeBridge = this.app.offline().realtimeBridge;
527
+ if (this.persistence && this.localStore && realtimeBridge) {
528
+ const localUnsub = this.localStore.subscribeToDocument(this.collection, this.id, callback);
529
+ const remoteUnsub = realtimeBridge.watchDocument(this.collection, this.id);
530
+ this.persistence.getDocSnapshot(this.collection, this.id).then((snapshot) => callback(snapshot, "initial")).catch(console.error);
531
+ return () => {
532
+ localUnsub();
533
+ remoteUnsub();
534
+ };
560
535
  }
561
- return this.app.rtdb().subscribeToDocumentRaw(this.collection, this.id, (snapshot, change) => {
562
- const res = normalizePayload(snapshot);
563
- callback(DocumentSnapshot.fromMap(res?.data), change);
536
+ this.fetchRemoteSnapshot().then((snapshot) => callback(snapshot, "initial")).catch(console.error);
537
+ return this.app.rtdb().subscribeToDocumentRaw(this.collection, this.id, (payload, change) => {
538
+ if (change === "delete") {
539
+ callback(null, "delete");
540
+ return;
541
+ }
542
+ callback(DocumentSnapshot.fromMap(payload), change);
564
543
  });
565
544
  }
545
+ async fetchRemoteSnapshot() {
546
+ try {
547
+ const res = await new HttpsRequest({
548
+ method: "POST" /* POST */,
549
+ endpoint: `${this.app.getBaseUrl()}/db/read`,
550
+ headers: {
551
+ authorization: this.app.getConfig().token,
552
+ "x-project": this.app.getConfig().project
553
+ },
554
+ body: {
555
+ collection: this.collection,
556
+ id: this.id,
557
+ single: true
558
+ }
559
+ }).sendRequest();
560
+ if (!res?.success || !res.document)
561
+ return null;
562
+ const snapshot = DocumentSnapshot.fromMap(res.document);
563
+ if (this.persistence) {
564
+ await this.persistence.applyRemoteDoc(this.collection, snapshot.data);
565
+ }
566
+ return snapshot;
567
+ } catch (error) {
568
+ console.error(`[DocumentRef] get(${this.collection}/${this.id}) failed:`, error);
569
+ return null;
570
+ }
571
+ }
572
+ async refreshFromRemote() {
573
+ const snapshot = await this.fetchRemoteSnapshot();
574
+ if (this.persistence && snapshot) {
575
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "update").catch(console.error);
576
+ }
577
+ return snapshot;
578
+ }
566
579
  };
567
580
 
581
+ // src/database/RealtimeListeners.ts
582
+ function buildTargetKey(collection, filter = {}) {
583
+ return `${collection}:${stableStringify(filter)}`;
584
+ }
585
+ function stableStringify(value) {
586
+ if (value === null || typeof value !== "object") {
587
+ return JSON.stringify(value);
588
+ }
589
+ if (Array.isArray(value)) {
590
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
591
+ }
592
+ const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
593
+ return `{${entries.join(",")}}`;
594
+ }
595
+ function diffSnapshots(previous, next) {
596
+ const previousById = new Map(previous.map((snapshot, index) => [snapshot.id, { snapshot, index }]));
597
+ const nextById = new Map(next.map((snapshot, index) => [snapshot.id, { snapshot, index }]));
598
+ const changes = [];
599
+ next.forEach((snapshot, index) => {
600
+ const before = previousById.get(snapshot.id);
601
+ if (!before) {
602
+ changes.push({
603
+ type: "added",
604
+ doc: snapshot,
605
+ previousDoc: null,
606
+ oldIndex: -1,
607
+ newIndex: index
608
+ });
609
+ return;
610
+ }
611
+ if (before.index !== index || !areSnapshotsEqual(before.snapshot, snapshot)) {
612
+ changes.push({
613
+ type: "modified",
614
+ doc: snapshot,
615
+ previousDoc: before.snapshot,
616
+ oldIndex: before.index,
617
+ newIndex: index
618
+ });
619
+ }
620
+ });
621
+ previous.forEach((snapshot, index) => {
622
+ if (nextById.has(snapshot.id))
623
+ return;
624
+ changes.push({
625
+ type: "removed",
626
+ doc: snapshot,
627
+ previousDoc: snapshot,
628
+ oldIndex: index,
629
+ newIndex: -1
630
+ });
631
+ });
632
+ return changes;
633
+ }
634
+ function applySnapshotChange(current, incoming, source) {
635
+ if (!incoming) {
636
+ return current;
637
+ }
638
+ if (source === "delete") {
639
+ return current.filter((snapshot) => snapshot.id !== incoming.id);
640
+ }
641
+ const next = [...current];
642
+ const index = next.findIndex((snapshot) => snapshot.id === incoming.id);
643
+ if (index === -1) {
644
+ next.push(incoming);
645
+ return next;
646
+ }
647
+ next[index] = incoming;
648
+ return next;
649
+ }
650
+ function areSnapshotsEqual(left, right) {
651
+ return stableStringify(left.data) === stableStringify(right.data);
652
+ }
653
+
568
654
  // src/database/Query.ts
569
655
  var Query = class {
570
656
  constructor(app, collection) {
@@ -577,6 +663,84 @@ var Query = class {
577
663
  this.filter.push(expression);
578
664
  return this;
579
665
  }
666
+ async get() {
667
+ const persistence = this.app.offline().persistence;
668
+ if (this.filter.length > 0 && persistence) {
669
+ const local = await this.getLocalFilteredSnapshots();
670
+ if (typeof navigator === "undefined" || !navigator.onLine) {
671
+ return local;
672
+ }
673
+ return this.refreshFromRemote();
674
+ }
675
+ if (persistence) {
676
+ const local = await persistence.getCollectionSnapshots(this.collection);
677
+ if (typeof navigator === "undefined" || navigator.onLine) {
678
+ this.refreshFromRemote().catch(() => {
679
+ });
680
+ }
681
+ return local;
682
+ }
683
+ return this.refreshFromRemote();
684
+ }
685
+ async update(data) {
686
+ const genfilter = this.buildFilter();
687
+ return new HttpsRequest({
688
+ method: "POST" /* POST */,
689
+ endpoint: `${this.app.getBaseUrl()}/db/update`,
690
+ headers: { authorization: this.app.getConfig().token, "x-project": this.app.getConfig().project },
691
+ body: {
692
+ collection: this.collection,
693
+ filter: genfilter,
694
+ data
695
+ }
696
+ }).sendRequest();
697
+ }
698
+ onSnapshot(callback) {
699
+ const genfilter = this.buildFilter();
700
+ const persistence = this.app.offline().persistence;
701
+ const realtimeBridge = this.app.offline().realtimeBridge;
702
+ if (persistence && this.localStore && realtimeBridge) {
703
+ const targetKey = buildTargetKey(this.collection, genfilter);
704
+ const localUnsub = this.localStore.subscribeToCollection(targetKey, callback);
705
+ const remoteUnsub = realtimeBridge.watchCollection(
706
+ this.collection,
707
+ genfilter,
708
+ () => this.getLocalFilteredSnapshots()
709
+ );
710
+ this.getLocalFilteredSnapshots().then((snapshots) => {
711
+ realtimeBridge.primeCollectionTarget(this.collection, genfilter, snapshots);
712
+ callback(snapshots, "initial");
713
+ }).catch(console.error);
714
+ return () => {
715
+ localUnsub();
716
+ remoteUnsub();
717
+ };
718
+ }
719
+ return this.subscribeOnlineSnapshot(callback);
720
+ }
721
+ onChildListener(callbacks) {
722
+ const genfilter = this.buildFilter();
723
+ const persistence = this.app.offline().persistence;
724
+ const realtimeBridge = this.app.offline().realtimeBridge;
725
+ if (persistence && this.localStore && realtimeBridge) {
726
+ const targetKey = buildTargetKey(this.collection, genfilter);
727
+ const localUnsub = this.localStore.subscribeToChildEvents(targetKey, callbacks);
728
+ const remoteUnsub = realtimeBridge.watchCollection(
729
+ this.collection,
730
+ genfilter,
731
+ () => this.getLocalFilteredSnapshots()
732
+ );
733
+ this.getLocalFilteredSnapshots().then((snapshots) => {
734
+ realtimeBridge.primeCollectionTarget(this.collection, genfilter, snapshots);
735
+ this.emitInitialChildEvents(snapshots, callbacks);
736
+ }).catch(console.error);
737
+ return () => {
738
+ localUnsub();
739
+ remoteUnsub();
740
+ };
741
+ }
742
+ return this.subscribeOnlineChildListener(callbacks);
743
+ }
580
744
  buildFilter() {
581
745
  const mongoFilter = {};
582
746
  this.filter.forEach(({ key, op, value }) => {
@@ -647,9 +811,7 @@ var Query = class {
647
811
  if (this.filter.length === 0)
648
812
  return docs;
649
813
  return docs.filter(
650
- (snapshot) => this.filter.every(
651
- (expression) => this.matchesFilter(snapshot.data, expression)
652
- )
814
+ (snapshot) => this.filter.every((expression) => this.matchesFilter(snapshot.data, expression))
653
815
  );
654
816
  }
655
817
  async getLocalFilteredSnapshots() {
@@ -659,25 +821,6 @@ var Query = class {
659
821
  const docs = await persistence.getCollectionSnapshots(this.collection);
660
822
  return this.filterDocuments(docs);
661
823
  }
662
- async get() {
663
- const persistence = this.app.offline().persistence;
664
- if (this.filter.length > 0 && persistence) {
665
- const local = await this.getLocalFilteredSnapshots();
666
- if (typeof navigator === "undefined" || !navigator.onLine) {
667
- return local;
668
- }
669
- return this.refreshFromRemote();
670
- }
671
- if (persistence) {
672
- const local = await persistence.getCollectionSnapshots(this.collection);
673
- if (typeof navigator === "undefined" || navigator.onLine) {
674
- this.refreshFromRemote().catch(() => {
675
- });
676
- }
677
- return local;
678
- }
679
- return this.refreshFromRemote();
680
- }
681
824
  async refreshFromRemote() {
682
825
  try {
683
826
  const genfilter = this.buildFilter();
@@ -693,69 +836,90 @@ var Query = class {
693
836
  if (!res?.success || !Array.isArray(res.documents)) {
694
837
  return [];
695
838
  }
696
- return res.documents.map((d) => {
839
+ const snapshots = res.documents.map((d) => {
697
840
  delete d.token;
698
841
  d.id = d.id ?? d._id;
699
842
  delete d._id;
700
843
  return DocumentSnapshot.fromMap(d);
701
844
  });
845
+ const persistence = this.app.offline().persistence;
846
+ if (!persistence) {
847
+ return snapshots;
848
+ }
849
+ for (const snapshot of snapshots) {
850
+ await persistence.applyRemoteDoc(this.collection, snapshot.data);
851
+ }
852
+ return this.getLocalFilteredSnapshots();
702
853
  } catch {
703
854
  return [];
704
855
  }
705
856
  }
706
- // Advanced: query-wide update (keep for now, but document it's server-side only)
707
- async update(data) {
708
- const genfilter = this.buildFilter();
709
- const res = await new HttpsRequest({
710
- method: "POST" /* POST */,
711
- endpoint: `${this.app.getBaseUrl()}/db/update`,
712
- headers: { authorization: this.app.getConfig().token, "x-project": this.app.getConfig().project },
713
- body: {
714
- collection: this.collection,
715
- filter: genfilter,
716
- data
717
- }
718
- }).sendRequest();
719
- return res;
857
+ subscribeOnlineSnapshot(callback) {
858
+ let current = [];
859
+ this.refreshFromRemote().then((snapshots) => {
860
+ current = snapshots;
861
+ callback(current, "initial");
862
+ }).catch(console.error);
863
+ return this.app.rtdb().subscribeToCollectionRaw(
864
+ this.collection,
865
+ (payload, change) => {
866
+ const source = change;
867
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
868
+ const next = applySnapshotChange(current, incoming, source);
869
+ current = this.filterDocuments(next);
870
+ callback(current, source, incoming.id);
871
+ },
872
+ this.buildFilter()
873
+ );
720
874
  }
721
- onSnapshot(callback) {
722
- const genfilter = this.buildFilter();
723
- const persistence = this.app.offline().persistence;
724
- if (persistence) {
725
- const remoteUnsub = this.app.offline().realtimeBridge?.watchCollection(this.collection, genfilter);
726
- if (this.filter.length > 0) {
727
- const emitFiltered = async (change, changedDocId) => {
728
- const docs = await this.getLocalFilteredSnapshots();
729
- callback(docs, change, changedDocId);
730
- };
731
- emitFiltered("insert").catch(console.error);
732
- const localUnsub2 = this.localStore?.subscribeToCollection(
733
- this.collection,
734
- async (snapshots, change, changedDocId) => {
735
- if (snapshots.length > 0) {
736
- callback(this.filterDocuments(snapshots), change, changedDocId);
737
- } else {
738
- await emitFiltered(change, changedDocId);
739
- }
875
+ subscribeOnlineChildListener(callbacks) {
876
+ let current = [];
877
+ this.refreshFromRemote().then((snapshots) => {
878
+ current = snapshots;
879
+ this.emitInitialChildEvents(snapshots, callbacks);
880
+ }).catch(console.error);
881
+ return this.app.rtdb().subscribeToCollectionRaw(
882
+ this.collection,
883
+ (payload, change) => {
884
+ const source = change;
885
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
886
+ const next = this.filterDocuments(applySnapshotChange(current, incoming, source));
887
+ const childChanges = diffSnapshots(current, next);
888
+ current = next;
889
+ childChanges.forEach((childChange) => {
890
+ const context = {
891
+ snapshots: next,
892
+ source,
893
+ changedDocId: childChange.doc.id,
894
+ fromCache: false,
895
+ oldIndex: childChange.oldIndex,
896
+ newIndex: childChange.newIndex,
897
+ previousDoc: childChange.previousDoc
898
+ };
899
+ if (childChange.type === "added") {
900
+ callbacks.onChildAdded?.(childChange.doc, context);
901
+ } else if (childChange.type === "modified") {
902
+ callbacks.onChildUpdated?.(childChange.doc, context);
903
+ } else if (childChange.type === "removed") {
904
+ callbacks.onChildRemoved?.(childChange.doc, context);
740
905
  }
741
- ) ?? (() => {
742
906
  });
743
- return () => {
744
- localUnsub2();
745
- remoteUnsub?.();
746
- };
747
- }
748
- const localUnsub = this.localStore?.subscribeToCollection(this.collection, callback) ?? (() => {
907
+ },
908
+ this.buildFilter()
909
+ );
910
+ }
911
+ emitInitialChildEvents(snapshots, callbacks) {
912
+ diffSnapshots([], snapshots).forEach((change) => {
913
+ callbacks.onChildAdded?.(change.doc, {
914
+ snapshots,
915
+ source: "initial",
916
+ changedDocId: change.doc.id,
917
+ fromCache: true,
918
+ oldIndex: change.oldIndex,
919
+ newIndex: change.newIndex,
920
+ previousDoc: change.previousDoc
749
921
  });
750
- return () => {
751
- localUnsub();
752
- remoteUnsub?.();
753
- };
754
- }
755
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
756
- const res = normalizePayload(payload);
757
- callback([DocumentSnapshot.fromMap(res?.data)], changes);
758
- }, genfilter);
922
+ });
759
923
  }
760
924
  };
761
925
 
@@ -787,6 +951,89 @@ var CollectionRef = class {
787
951
  }
788
952
  return local;
789
953
  }
954
+ return this.fetchRemoteSnapshots();
955
+ }
956
+ async add(data) {
957
+ if (this.persistence) {
958
+ const localId = this.persistence.createLocalId();
959
+ const docRecord = await this.persistence.upsertDoc({
960
+ collection: this.collection,
961
+ id: localId,
962
+ data: { ...data, id: localId },
963
+ exists: true,
964
+ deleted: false,
965
+ pending: 1,
966
+ localOnly: true,
967
+ status: "pending"
968
+ });
969
+ await this.persistence.enqueueMutation({
970
+ mutationId: this.persistence.createMutationId(),
971
+ collection: this.collection,
972
+ documentId: localId,
973
+ type: "insert",
974
+ payload: docRecord.data
975
+ });
976
+ const snap = DocumentSnapshot.fromMap(docRecord.data);
977
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, localId, "insert").catch(console.error);
978
+ this.syncEngine?.flush().catch(console.error);
979
+ return snap;
980
+ }
981
+ const res = await new HttpsRequest({
982
+ method: "POST" /* POST */,
983
+ endpoint: `${this.app.getBaseUrl()}/db/create`,
984
+ headers: {
985
+ authorization: this.app.getConfig().token,
986
+ "x-project": this.app.getConfig().project
987
+ },
988
+ body: { collection: this.collection, data: { ...data, id: "" } }
989
+ }).sendRequest();
990
+ if (!res?.success || !res.document)
991
+ return null;
992
+ return DocumentSnapshot.fromMap(res.document);
993
+ }
994
+ onSnapshot(callback) {
995
+ const realtimeBridge = this.app.offline().realtimeBridge;
996
+ if (this.persistence && this.localStore && realtimeBridge) {
997
+ const targetKey = buildTargetKey(this.collection, {});
998
+ const localUnsub = this.localStore.subscribeToCollection(targetKey, callback);
999
+ const remoteUnsub = realtimeBridge.watchCollection(
1000
+ this.collection,
1001
+ {},
1002
+ () => this.persistence.getCollectionSnapshots(this.collection)
1003
+ );
1004
+ this.persistence.getCollectionSnapshots(this.collection).then((snapshots) => {
1005
+ realtimeBridge.primeCollectionTarget(this.collection, {}, snapshots);
1006
+ callback(snapshots, "insert");
1007
+ }).catch(console.error);
1008
+ return () => {
1009
+ localUnsub();
1010
+ remoteUnsub();
1011
+ };
1012
+ }
1013
+ return this.subscribeOnlineSnapshot(callback);
1014
+ }
1015
+ onChildListener(callbacks) {
1016
+ const realtimeBridge = this.app.offline().realtimeBridge;
1017
+ if (this.persistence && this.localStore && realtimeBridge) {
1018
+ const targetKey = buildTargetKey(this.collection, {});
1019
+ const localUnsub = this.localStore.subscribeToChildEvents(targetKey, callbacks);
1020
+ const remoteUnsub = realtimeBridge.watchCollection(
1021
+ this.collection,
1022
+ {},
1023
+ () => this.persistence.getCollectionSnapshots(this.collection)
1024
+ );
1025
+ this.persistence.getCollectionSnapshots(this.collection).then((snapshots) => {
1026
+ realtimeBridge.primeCollectionTarget(this.collection, {}, snapshots);
1027
+ this.emitInitialChildEvents(snapshots, callbacks);
1028
+ }).catch(console.error);
1029
+ return () => {
1030
+ localUnsub();
1031
+ remoteUnsub();
1032
+ };
1033
+ }
1034
+ return this.subscribeOnlineChildListener(callbacks);
1035
+ }
1036
+ async refreshFromRemote() {
790
1037
  try {
791
1038
  const res = await new HttpsRequest({
792
1039
  method: "POST" /* POST */,
@@ -803,18 +1050,25 @@ var CollectionRef = class {
803
1050
  if (!res?.success || !Array.isArray(res.documents)) {
804
1051
  return [];
805
1052
  }
806
- return res.documents.map((d) => {
807
- delete d.token;
808
- d.id = d.id ?? d._id;
809
- delete d._id;
810
- return DocumentSnapshot.fromMap(d);
1053
+ const normalized = res.documents.map((raw) => {
1054
+ const doc = { ...raw };
1055
+ doc.id = doc.id ?? doc._id;
1056
+ delete doc._id;
1057
+ return doc;
811
1058
  });
1059
+ if (this.persistence) {
1060
+ await this.persistence.reconcileCollectionFromRemote(this.collection, normalized);
1061
+ const snapshots = await this.persistence.getCollectionSnapshots(this.collection);
1062
+ this.app.offline().realtimeBridge?.primeCollectionTarget(this.collection, {}, snapshots);
1063
+ return snapshots;
1064
+ }
1065
+ return normalized.map((doc) => DocumentSnapshot.fromMap(doc));
812
1066
  } catch (error) {
813
- console.error("[EdmaxLabs] Collection get failed:", error);
1067
+ console.error("[EdmaxLabs] refreshFromRemote failed:", error);
814
1068
  return [];
815
1069
  }
816
1070
  }
817
- async refreshFromRemote() {
1071
+ async fetchRemoteSnapshots() {
818
1072
  try {
819
1073
  const res = await new HttpsRequest({
820
1074
  method: "POST" /* POST */,
@@ -831,17 +1085,6 @@ var CollectionRef = class {
831
1085
  if (!res?.success || !Array.isArray(res.documents)) {
832
1086
  return [];
833
1087
  }
834
- for (const raw of res.documents) {
835
- const doc = { ...raw };
836
- doc.id = doc.id ?? doc._id;
837
- delete doc._id;
838
- if (this.persistence) {
839
- await this.persistence.applyRemoteDoc(this.collection, doc);
840
- }
841
- }
842
- if (this.persistence) {
843
- return await this.persistence.getCollectionSnapshots(this.collection);
844
- }
845
1088
  return res.documents.map((d) => {
846
1089
  delete d.token;
847
1090
  d.id = d.id ?? d._id;
@@ -849,59 +1092,68 @@ var CollectionRef = class {
849
1092
  return DocumentSnapshot.fromMap(d);
850
1093
  });
851
1094
  } catch (error) {
852
- console.error("[EdmaxLabs] refreshFromRemote failed:", error);
1095
+ console.error("[EdmaxLabs] Collection get failed:", error);
853
1096
  return [];
854
1097
  }
855
1098
  }
856
- async add(data) {
857
- if (this.persistence) {
858
- const localId = this.persistence.createLocalId();
859
- const docRecord = await this.persistence.upsertDoc({
860
- collection: this.collection,
861
- id: localId,
862
- data: { ...data, id: localId },
863
- exists: true,
864
- deleted: false,
865
- pending: 1,
866
- localOnly: true,
867
- status: "pending"
868
- });
869
- await this.persistence.enqueueMutation({
870
- mutationId: this.persistence.createMutationId(),
871
- collection: this.collection,
872
- documentId: localId,
873
- type: "insert",
874
- payload: docRecord.data
875
- });
876
- const snap = DocumentSnapshot.fromMap(docRecord.data);
877
- this.localStore?.emitDocument(this.collection, localId, snap, "insert");
878
- this.localStore?.notifyCollectionChanged(this.collection, localId, "insert");
879
- this.syncEngine?.flush().catch(console.error);
880
- return snap;
881
- }
882
- const res = await new HttpsRequest({
883
- method: "POST" /* POST */,
884
- endpoint: `${this.app.getBaseUrl()}/db/create`,
885
- headers: {
886
- authorization: this.app.getConfig().token,
887
- "x-project": this.app.getConfig().project
888
- },
889
- body: { collection: this.collection, data: { ...data, id: "" } }
890
- // server will generate id
891
- }).sendRequest();
892
- if (!res?.success || !res.document)
893
- return null;
894
- return DocumentSnapshot.fromMap(res.document);
1099
+ subscribeOnlineSnapshot(callback) {
1100
+ let current = [];
1101
+ this.fetchRemoteSnapshots().then((snapshots) => {
1102
+ current = snapshots;
1103
+ callback(current, "initial");
1104
+ }).catch(console.error);
1105
+ return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, change) => {
1106
+ const source = change;
1107
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
1108
+ current = applySnapshotChange(current, incoming, source);
1109
+ callback(current, source, incoming.id);
1110
+ });
895
1111
  }
896
- onSnapshot(callback) {
897
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
898
- this.app.offline().realtimeBridge?.watchCollection(this.collection);
899
- return this.localStore.subscribeToCollection(this.collection, callback) ?? (() => {
1112
+ subscribeOnlineChildListener(callbacks) {
1113
+ let current = [];
1114
+ this.fetchRemoteSnapshots().then((snapshots) => {
1115
+ current = snapshots;
1116
+ this.emitInitialChildEvents(snapshots, callbacks);
1117
+ }).catch(console.error);
1118
+ return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, change) => {
1119
+ const source = change;
1120
+ const incoming = source === "delete" ? DocumentSnapshot.fromMap({ id: payload?.id ?? payload?._id }) : DocumentSnapshot.fromMap(payload);
1121
+ const next = applySnapshotChange(current, incoming, source);
1122
+ const childChanges = diffSnapshots(current, next);
1123
+ current = next;
1124
+ childChanges.forEach((childChange) => {
1125
+ const context = {
1126
+ snapshots: next,
1127
+ source,
1128
+ changedDocId: childChange.doc.id,
1129
+ fromCache: false,
1130
+ oldIndex: childChange.oldIndex,
1131
+ newIndex: childChange.newIndex,
1132
+ previousDoc: childChange.previousDoc
1133
+ };
1134
+ if (childChange.type === "added") {
1135
+ callbacks.onChildAdded?.(childChange.doc, context);
1136
+ } else if (childChange.type === "modified") {
1137
+ callbacks.onChildUpdated?.(childChange.doc, context);
1138
+ } else if (childChange.type === "removed") {
1139
+ callbacks.onChildRemoved?.(childChange.doc, context);
1140
+ }
900
1141
  });
901
- }
902
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
903
- const res = normalizePayload(payload);
904
- callback([DocumentSnapshot.fromMap(res?.data)], changes);
1142
+ });
1143
+ }
1144
+ emitInitialChildEvents(snapshots, callbacks) {
1145
+ const childChanges = diffSnapshots([], snapshots);
1146
+ childChanges.forEach((change) => {
1147
+ const context = {
1148
+ snapshots,
1149
+ source: "initial",
1150
+ changedDocId: change.doc.id,
1151
+ fromCache: true,
1152
+ oldIndex: change.oldIndex,
1153
+ newIndex: change.newIndex,
1154
+ previousDoc: change.previousDoc
1155
+ };
1156
+ callbacks.onChildAdded?.(change.doc, context);
905
1157
  });
906
1158
  }
907
1159
  };
@@ -951,6 +1203,7 @@ var Batch = class {
951
1203
  const persistence = this.app.offline().persistence;
952
1204
  const localStore = this.app.offline().localStore;
953
1205
  const syncEngine = this.app.offline().syncEngine;
1206
+ const realtimeBridge = this.app.offline().realtimeBridge;
954
1207
  if (persistence && localStore) {
955
1208
  const results = [];
956
1209
  for (const op of this.ops) {
@@ -979,8 +1232,7 @@ var Batch = class {
979
1232
  payload: op.data
980
1233
  });
981
1234
  const snap = DocumentSnapshot.fromMap(upserted.data);
982
- localStore.emitDocument(op.collection, op.id, snap, "insert");
983
- localStore.notifyCollectionChanged(op.collection, op.id, "insert");
1235
+ realtimeBridge?.publishLocalChange(op.collection, op.id, "insert").catch(console.error);
984
1236
  results.push(snap);
985
1237
  } else if (op.op === "delete") {
986
1238
  const docRef = new DocumentRef(this.app, op.collection, op.id);
@@ -1092,6 +1344,20 @@ function generateUUID() {
1092
1344
  });
1093
1345
  }
1094
1346
 
1347
+ // src/utils/documentNomalizer.ts
1348
+ function normalizePayload(payload) {
1349
+ const raw = payload?.document ?? payload?.data ?? payload;
1350
+ if (!raw)
1351
+ return null;
1352
+ const doc = { ...raw };
1353
+ doc.id = doc.id ?? raw?.id ?? raw?._id ?? payload?.id ?? payload?._id;
1354
+ delete doc._id;
1355
+ return {
1356
+ change: payload?.change ?? raw?.change ?? null,
1357
+ data: doc
1358
+ };
1359
+ }
1360
+
1095
1361
  // src/database/Realtime.ts
1096
1362
  var Realtime = class {
1097
1363
  constructor(app) {
@@ -1160,8 +1426,12 @@ var Realtime = class {
1160
1426
  const channel = `${lid}-${event}`;
1161
1427
  const fn = (payload) => {
1162
1428
  const normalized = normalizePayload(payload);
1163
- if (!normalized)
1429
+ if (!normalized) {
1430
+ if (event === "delete") {
1431
+ callback(payload, "delete");
1432
+ }
1164
1433
  return;
1434
+ }
1165
1435
  callback(normalized.data, event);
1166
1436
  };
1167
1437
  this.socket.on(channel, fn);
@@ -1285,11 +1555,11 @@ var LocalStore = class {
1285
1555
  constructor() {
1286
1556
  this.documentListeners = /* @__PURE__ */ new Map();
1287
1557
  this.collectionListeners = /* @__PURE__ */ new Map();
1558
+ this.childListeners = /* @__PURE__ */ new Map();
1288
1559
  }
1289
1560
  docKey(collection, id) {
1290
1561
  return `${collection}:${id}`;
1291
1562
  }
1292
- // ===================== DOCUMENT LISTENERS =====================
1293
1563
  subscribeToDocument(collection, id, callback) {
1294
1564
  const key = this.docKey(collection, id);
1295
1565
  if (!this.documentListeners.has(key)) {
@@ -1304,24 +1574,33 @@ var LocalStore = class {
1304
1574
  }
1305
1575
  };
1306
1576
  }
1307
- // ===================== COLLECTION LISTENERS =====================
1308
- subscribeToCollection(collection, callback) {
1309
- if (!this.collectionListeners.has(collection)) {
1310
- this.collectionListeners.set(collection, /* @__PURE__ */ new Set());
1577
+ subscribeToCollection(targetKey, callback) {
1578
+ if (!this.collectionListeners.has(targetKey)) {
1579
+ this.collectionListeners.set(targetKey, /* @__PURE__ */ new Set());
1311
1580
  }
1312
- const listeners = this.collectionListeners.get(collection);
1581
+ const listeners = this.collectionListeners.get(targetKey);
1313
1582
  listeners.add(callback);
1314
1583
  return () => {
1315
1584
  listeners.delete(callback);
1316
1585
  if (listeners.size === 0) {
1317
- this.collectionListeners.delete(collection);
1586
+ this.collectionListeners.delete(targetKey);
1587
+ this.childListeners.delete(targetKey);
1588
+ }
1589
+ };
1590
+ }
1591
+ subscribeToChildEvents(targetKey, callbacks) {
1592
+ if (!this.childListeners.has(targetKey)) {
1593
+ this.childListeners.set(targetKey, /* @__PURE__ */ new Set());
1594
+ }
1595
+ const listeners = this.childListeners.get(targetKey);
1596
+ listeners.add(callbacks);
1597
+ return () => {
1598
+ listeners.delete(callbacks);
1599
+ if (listeners.size === 0) {
1600
+ this.childListeners.delete(targetKey);
1318
1601
  }
1319
1602
  };
1320
1603
  }
1321
- // ===================== EMITTERS =====================
1322
- /**
1323
- * Notify all listeners for a specific document
1324
- */
1325
1604
  emitDocument(collection, id, snapshot, change) {
1326
1605
  const key = this.docKey(collection, id);
1327
1606
  this.documentListeners.get(key)?.forEach((cb) => {
@@ -1332,64 +1611,66 @@ var LocalStore = class {
1332
1611
  }
1333
1612
  });
1334
1613
  }
1335
- /**
1336
- * Notify all listeners for a collection.
1337
- * This is the most important fix — collection listeners now receive the full list.
1338
- */
1339
- emitCollection(collection, snapshots, change, changedDocId) {
1340
- this.collectionListeners.get(collection)?.forEach((cb) => {
1614
+ emitCollection(targetKey, emission) {
1615
+ this.collectionListeners.get(targetKey)?.forEach((cb) => {
1341
1616
  try {
1342
- cb(snapshots, change, changedDocId);
1617
+ cb(emission.snapshots, emission.source, emission.changedDocId);
1343
1618
  } catch (err) {
1344
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1619
+ console.error(`[EdmaxLabs] Error in collection listener for ${targetKey}:`, err);
1345
1620
  }
1346
1621
  });
1347
- }
1348
- /**
1349
- * OPTIMIZED: Notify collection listeners that something changed, without fetching full collection.
1350
- * Listeners can call getCollectionSnapshots() themselves if they need the full list.
1351
- * This avoids expensive collection queries after every single mutation.
1352
- */
1353
- notifyCollectionChanged(collection, changedDocId, change) {
1354
- this.collectionListeners.get(collection)?.forEach((cb) => {
1355
- try {
1356
- cb([], change, changedDocId);
1357
- } catch (err) {
1358
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1359
- }
1622
+ const childChanges = emission.childChanges ?? [];
1623
+ if (childChanges.length === 0)
1624
+ return;
1625
+ this.childListeners.get(targetKey)?.forEach((callbacks) => {
1626
+ childChanges.forEach((change) => {
1627
+ this.dispatchChildEvent(callbacks, change, emission);
1628
+ });
1360
1629
  });
1361
1630
  }
1362
- // ===================== UTILITY =====================
1363
- /**
1364
- * Clear all listeners (useful for testing or when persistence is disabled)
1365
- */
1366
1631
  clearAllListeners() {
1367
1632
  this.documentListeners.clear();
1368
1633
  this.collectionListeners.clear();
1634
+ this.childListeners.clear();
1369
1635
  }
1370
- /**
1371
- * Get current listener count (for debugging / dev tools)
1372
- */
1373
1636
  get listenerCount() {
1374
1637
  let docCount = 0;
1375
1638
  this.documentListeners.forEach((set) => docCount += set.size);
1376
1639
  let collCount = 0;
1377
1640
  this.collectionListeners.forEach((set) => collCount += set.size);
1641
+ this.childListeners.forEach((set) => collCount += set.size);
1378
1642
  return { documents: docCount, collections: collCount };
1379
1643
  }
1380
- /**
1381
- * Remove all listeners for a specific collection (useful for cleanup)
1382
- */
1383
- removeCollectionListeners(collection) {
1384
- this.collectionListeners.delete(collection);
1644
+ removeCollectionListeners(targetKey) {
1645
+ this.collectionListeners.delete(targetKey);
1646
+ this.childListeners.delete(targetKey);
1385
1647
  }
1386
- /**
1387
- * Remove all listeners for a specific document (useful for cleanup)
1388
- */
1389
1648
  removeDocumentListeners(collection, id) {
1390
1649
  const key = this.docKey(collection, id);
1391
1650
  this.documentListeners.delete(key);
1392
1651
  }
1652
+ dispatchChildEvent(callbacks, change, emission) {
1653
+ const context = {
1654
+ snapshots: emission.snapshots,
1655
+ source: emission.source,
1656
+ changedDocId: emission.changedDocId,
1657
+ fromCache: emission.fromCache ?? true,
1658
+ oldIndex: change.oldIndex,
1659
+ newIndex: change.newIndex,
1660
+ previousDoc: change.previousDoc
1661
+ };
1662
+ try {
1663
+ if (change.type === "added") {
1664
+ callbacks.onChildAdded?.(change.doc, context);
1665
+ } else if (change.type === "modified") {
1666
+ callbacks.onChildUpdated?.(change.doc, context);
1667
+ } else if (change.type === "removed") {
1668
+ callbacks.onChildRemoved?.(change.doc, context);
1669
+ }
1670
+ } catch (err) {
1671
+ console.error("[EdmaxLabs] Error in child listener:", err);
1672
+ }
1673
+ }
1393
1674
  };
1394
1675
 
1395
1676
  // ../../../../node_modules/idb/build/wrap-idb-value.js
@@ -1760,11 +2041,25 @@ var Persistence = class {
1760
2041
  }
1761
2042
  async getPendingMutations() {
1762
2043
  const app = await this.getDb();
1763
- const [pending, failed] = await Promise.all([
1764
- app.getAllFromIndex("mutations", "by-status", "pending"),
1765
- app.getAllFromIndex("mutations", "by-status", "failed")
1766
- ]);
1767
- return [...pending, ...failed].sort((a, b) => a.createdAt - b.createdAt);
2044
+ const pending = await app.getAllFromIndex("mutations", "by-status", "pending");
2045
+ return pending.sort((a, b) => a.createdAt - b.createdAt);
2046
+ }
2047
+ async getFailedMutations() {
2048
+ const app = await this.getDb();
2049
+ const failed = await app.getAllFromIndex("mutations", "by-status", "failed");
2050
+ return failed.sort((a, b) => a.createdAt - b.createdAt);
2051
+ }
2052
+ async recoverSyncingMutations() {
2053
+ const app = await this.getDb();
2054
+ const syncing = await app.getAllFromIndex("mutations", "by-status", "syncing");
2055
+ for (const mutation of syncing) {
2056
+ await app.put("mutations", {
2057
+ ...mutation,
2058
+ status: "pending",
2059
+ updatedAt: this.now()
2060
+ });
2061
+ }
2062
+ return syncing.length;
1768
2063
  }
1769
2064
  async getMutation(mutationId) {
1770
2065
  const app = await this.getDb();
@@ -1869,6 +2164,27 @@ var Persistence = class {
1869
2164
  }
1870
2165
  return this.markDeleted(collection, id, 0);
1871
2166
  }
2167
+ async reconcileCollectionFromRemote(collection, documents) {
2168
+ const app = await this.getDb();
2169
+ const existing = await app.getAllFromIndex("docs", "by-collection", collection);
2170
+ const incomingIds = /* @__PURE__ */ new Set();
2171
+ for (const data of documents) {
2172
+ const id = data.id ?? data._id;
2173
+ if (!id)
2174
+ continue;
2175
+ incomingIds.add(String(id));
2176
+ await this.applyRemoteDoc(collection, { ...data, id: String(id) });
2177
+ }
2178
+ for (const doc of existing) {
2179
+ if (doc.pending && doc.pending > 0)
2180
+ continue;
2181
+ if (!doc.exists || doc.deleted)
2182
+ continue;
2183
+ if (incomingIds.has(doc.id))
2184
+ continue;
2185
+ await this.markDeleted(collection, doc.id, 0);
2186
+ }
2187
+ }
1872
2188
  // ==================== UTILITIES ====================
1873
2189
  createLocalId() {
1874
2190
  return `local_${crypto.randomUUID()}`;
@@ -1898,112 +2214,169 @@ var RealtimeBridge = class {
1898
2214
  this.app = app;
1899
2215
  this.persistence = persistence;
1900
2216
  this.store = store;
1901
- this.collectionUnsubs = /* @__PURE__ */ new Map();
1902
- this.documentUnsubs = /* @__PURE__ */ new Map();
2217
+ this.collectionTargets = /* @__PURE__ */ new Map();
2218
+ this.documentTargets = /* @__PURE__ */ new Map();
1903
2219
  }
1904
2220
  docKey(collection, id) {
1905
2221
  return `${collection}:${id}`;
1906
2222
  }
1907
- // ===================== PUBLIC API =====================
1908
- watchCollection(collection, filter = {}) {
1909
- const key = `${collection}:${JSON.stringify(filter)}`;
1910
- if (this.collectionUnsubs.has(key)) {
1911
- return this.collectionUnsubs.get(key);
2223
+ watchCollection(collection, filter = {}, read) {
2224
+ const targetKey = buildTargetKey(collection, filter);
2225
+ const existing = this.collectionTargets.get(targetKey);
2226
+ if (existing) {
2227
+ existing.refCount += 1;
2228
+ return () => this.releaseCollection(targetKey);
1912
2229
  }
1913
- this.emitCurrentCollection(collection);
1914
2230
  const unsub = this.app.rtdb().subscribeToCollectionRaw(
1915
2231
  collection,
1916
2232
  async (payload, change) => {
1917
2233
  if (change === "delete") {
1918
2234
  const id = payload?.id || payload?._id;
1919
- if (id)
2235
+ if (id) {
1920
2236
  await this.handleRemoteDelete(collection, id);
1921
- } else {
1922
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2237
+ }
2238
+ return;
1923
2239
  }
2240
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1924
2241
  },
1925
2242
  filter
1926
2243
  );
1927
- const fullUnsub = () => {
1928
- unsub();
1929
- this.collectionUnsubs.delete(key);
1930
- };
1931
- this.collectionUnsubs.set(key, fullUnsub);
1932
- return fullUnsub;
2244
+ this.collectionTargets.set(targetKey, {
2245
+ targetKey,
2246
+ collection,
2247
+ filter,
2248
+ read,
2249
+ refCount: 1,
2250
+ unsub,
2251
+ lastSnapshots: []
2252
+ });
2253
+ return () => this.releaseCollection(targetKey);
1933
2254
  }
1934
2255
  watchDocument(collection, id) {
1935
2256
  const key = this.docKey(collection, id);
1936
- if (this.documentUnsubs.has(key)) {
1937
- return this.documentUnsubs.get(key);
2257
+ const existing = this.documentTargets.get(key);
2258
+ if (existing) {
2259
+ existing.refCount += 1;
2260
+ return () => this.releaseDocument(key);
1938
2261
  }
1939
- this.emitCurrentDocument(collection, id);
1940
2262
  const unsub = this.app.rtdb().subscribeToDocumentRaw(
1941
2263
  collection,
1942
2264
  id,
1943
2265
  async (payload, change) => {
1944
2266
  if (change === "delete") {
1945
2267
  await this.handleRemoteDelete(collection, id);
1946
- } else {
1947
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2268
+ return;
1948
2269
  }
2270
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1949
2271
  }
1950
2272
  );
1951
- const fullUnsub = () => {
1952
- unsub();
1953
- this.documentUnsubs.delete(key);
1954
- };
1955
- this.documentUnsubs.set(key, fullUnsub);
1956
- return fullUnsub;
2273
+ this.documentTargets.set(key, {
2274
+ key,
2275
+ collection,
2276
+ id,
2277
+ refCount: 1,
2278
+ unsub
2279
+ });
2280
+ return () => this.releaseDocument(key);
2281
+ }
2282
+ async emitCurrentCollection(collection, filter = {}) {
2283
+ const targetKey = buildTargetKey(collection, filter);
2284
+ const target = this.collectionTargets.get(targetKey);
2285
+ if (!target)
2286
+ return [];
2287
+ return this.refreshCollectionTarget(target, "initial");
2288
+ }
2289
+ primeCollectionTarget(collection, filter, snapshots) {
2290
+ const targetKey = buildTargetKey(collection, filter);
2291
+ const target = this.collectionTargets.get(targetKey);
2292
+ if (!target)
2293
+ return;
2294
+ target.lastSnapshots = snapshots;
1957
2295
  }
1958
- // ===================== INTERNAL HANDLERS =====================
1959
2296
  async emitCurrentDocument(collection, id) {
1960
- const localDoc = await this.persistence.getDoc(collection, id);
1961
- const snapshot = localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
1962
- this.store.emitDocument(collection, id, snapshot, "insert");
2297
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2298
+ this.store.emitDocument(collection, id, snapshot, "initial");
2299
+ return snapshot;
1963
2300
  }
1964
- async emitCurrentCollection(collection) {
1965
- const localDocs = await this.persistence.getCollectionSnapshots(collection);
1966
- this.store.emitCollection(collection, localDocs, "insert");
2301
+ async publishLocalChange(collection, id, source) {
2302
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2303
+ this.store.emitDocument(collection, id, snapshot, source);
2304
+ await this.refreshCollectionTargets(collection, source, id);
2305
+ }
2306
+ async onReconnect() {
2307
+ for (const target of this.collectionTargets.values()) {
2308
+ await this.refreshCollectionTarget(target, "initial");
2309
+ }
2310
+ for (const target of this.documentTargets.values()) {
2311
+ await this.emitCurrentDocument(target.collection, target.id);
2312
+ }
2313
+ }
2314
+ dispose() {
2315
+ this.collectionTargets.forEach((target) => target.unsub());
2316
+ this.documentTargets.forEach((target) => target.unsub());
2317
+ this.collectionTargets.clear();
2318
+ this.documentTargets.clear();
1967
2319
  }
1968
- async handleRemoteCreateOrUpdate(collection, raw, change) {
2320
+ async handleRemoteCreateOrUpdate(collection, raw, source) {
1969
2321
  const id = raw.id ?? raw._id;
1970
2322
  if (!id)
1971
2323
  return;
1972
2324
  const saved = await this.persistence.applyRemoteDoc(collection, raw);
1973
- if (!saved)
1974
- return;
1975
- const snap = DocumentSnapshot.fromMap(saved.data);
1976
- this.store.emitDocument(collection, id, snap, change);
1977
- this.store.notifyCollectionChanged(collection, id, change);
2325
+ if (saved) {
2326
+ const snap = DocumentSnapshot.fromMap(saved.data);
2327
+ this.store.emitDocument(collection, id, snap, source);
2328
+ }
2329
+ await this.refreshCollectionTargets(collection, source, id, false);
1978
2330
  }
1979
2331
  async handleRemoteDelete(collection, id) {
1980
2332
  await this.persistence.applyRemoteDelete(collection, id);
1981
2333
  this.store.emitDocument(collection, id, null, "delete");
1982
- this.store.notifyCollectionChanged(collection, id, "delete");
2334
+ await this.refreshCollectionTargets(collection, "delete", id, false);
1983
2335
  }
1984
- // ===================== LIFECYCLE =====================
1985
- /**
1986
- * Call this when Socket.IO reconnects or when going online
1987
- * Replays pending mutations + refreshes all active subscriptions from cache
1988
- */
1989
- async onReconnect() {
1990
- for (const [key] of this.collectionUnsubs) {
1991
- const collection = key.split(":")[0];
1992
- await this.emitCurrentCollection(collection);
1993
- }
1994
- for (const [key] of this.documentUnsubs) {
1995
- const [collection, id] = key.split(":");
1996
- await this.emitCurrentDocument(collection, id);
1997
- }
2336
+ async getCurrentDocumentSnapshot(collection, id) {
2337
+ const localDoc = await this.persistence.getDoc(collection, id);
2338
+ return localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
1998
2339
  }
1999
- /**
2000
- * Clean up all subscriptions (call from EdmaxLabs destructor if needed)
2001
- */
2002
- dispose() {
2003
- this.collectionUnsubs.forEach((unsub) => unsub());
2004
- this.documentUnsubs.forEach((unsub) => unsub());
2005
- this.collectionUnsubs.clear();
2006
- this.documentUnsubs.clear();
2340
+ async refreshCollectionTargets(collection, source, changedDocId, fromCache = true) {
2341
+ const targets = Array.from(this.collectionTargets.values()).filter(
2342
+ (target) => target.collection === collection
2343
+ );
2344
+ for (const target of targets) {
2345
+ await this.refreshCollectionTarget(target, source, changedDocId, fromCache);
2346
+ }
2347
+ }
2348
+ async refreshCollectionTarget(target, source, changedDocId, fromCache = true) {
2349
+ const snapshots = await target.read();
2350
+ const childChanges = diffSnapshots(target.lastSnapshots, snapshots);
2351
+ target.lastSnapshots = snapshots;
2352
+ this.store.emitCollection(target.targetKey, {
2353
+ snapshots,
2354
+ source,
2355
+ changedDocId,
2356
+ fromCache,
2357
+ childChanges
2358
+ });
2359
+ return snapshots;
2360
+ }
2361
+ releaseCollection(targetKey) {
2362
+ const target = this.collectionTargets.get(targetKey);
2363
+ if (!target)
2364
+ return;
2365
+ target.refCount -= 1;
2366
+ if (target.refCount > 0)
2367
+ return;
2368
+ target.unsub();
2369
+ this.collectionTargets.delete(targetKey);
2370
+ }
2371
+ releaseDocument(key) {
2372
+ const target = this.documentTargets.get(key);
2373
+ if (!target)
2374
+ return;
2375
+ target.refCount -= 1;
2376
+ if (target.refCount > 0)
2377
+ return;
2378
+ target.unsub();
2379
+ this.documentTargets.delete(key);
2007
2380
  }
2008
2381
  };
2009
2382
 
@@ -2015,16 +2388,19 @@ var SyncEngine = class {
2015
2388
  this.retryTimeout = null;
2016
2389
  this.MAX_RETRIES = 5;
2017
2390
  this.BASE_RETRY_DELAY = 2e3;
2391
+ this.handleOnline = () => this.onNetworkOnline();
2392
+ this.handleVisibilityChange = () => {
2393
+ if (document.visibilityState === "visible")
2394
+ this.onNetworkOnline();
2395
+ };
2018
2396
  this.app = app;
2019
2397
  this.persistence = persistence;
2020
2398
  this.store = store;
2021
2399
  this.realtimeBridge = realtimeBridge || null;
2400
+ this.persistence.recoverSyncingMutations().catch(console.error);
2022
2401
  if (typeof window !== "undefined") {
2023
- window.addEventListener("online", () => this.onNetworkOnline());
2024
- document.addEventListener("visibilitychange", () => {
2025
- if (document.visibilityState === "visible")
2026
- this.onNetworkOnline();
2027
- });
2402
+ window.addEventListener("online", this.handleOnline);
2403
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
2028
2404
  }
2029
2405
  }
2030
2406
  onNetworkOnline() {
@@ -2041,6 +2417,7 @@ var SyncEngine = class {
2041
2417
  return;
2042
2418
  this.syncing = true;
2043
2419
  try {
2420
+ await this.persistence.recoverSyncingMutations();
2044
2421
  const pending = await this.persistence.getPendingMutations();
2045
2422
  for (const mutation of pending) {
2046
2423
  if (mutation.retryCount >= this.MAX_RETRIES) {
@@ -2117,8 +2494,7 @@ var SyncEngine = class {
2117
2494
  if (replaced) {
2118
2495
  const snap = DocumentSnapshot.fromMap(replaced.data);
2119
2496
  this.store.emitDocument(mutation.collection, oldId, snap, "insert");
2120
- this.store.emitDocument(mutation.collection, newId, snap, "insert");
2121
- this.store.notifyCollectionChanged(mutation.collection, newId, "insert");
2497
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, newId, "insert");
2122
2498
  }
2123
2499
  return true;
2124
2500
  }
@@ -2151,7 +2527,7 @@ var SyncEngine = class {
2151
2527
  });
2152
2528
  const snap = DocumentSnapshot.fromMap(local.data);
2153
2529
  this.store.emitDocument(mutation.collection, mutation.documentId, snap, "update");
2154
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "update");
2530
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "update");
2155
2531
  return true;
2156
2532
  }
2157
2533
  async syncDelete(mutation) {
@@ -2168,7 +2544,7 @@ var SyncEngine = class {
2168
2544
  return false;
2169
2545
  await this.persistence.markDeleted(mutation.collection, mutation.documentId, 0);
2170
2546
  this.store.emitDocument(mutation.collection, mutation.documentId, null, "delete");
2171
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "delete");
2547
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "delete");
2172
2548
  return true;
2173
2549
  }
2174
2550
  scheduleRetry() {
@@ -2188,8 +2564,7 @@ var SyncEngine = class {
2188
2564
  * Returns mutations that exceeded MAX_RETRIES
2189
2565
  */
2190
2566
  async getFailedMutations() {
2191
- const all = await this.persistence.getPendingMutations();
2192
- return all.filter((m) => m.status === "failed");
2567
+ return this.persistence.getFailedMutations();
2193
2568
  }
2194
2569
  /**
2195
2570
  * Retry a specific failed mutation by resetting its retry count
@@ -2231,6 +2606,10 @@ var SyncEngine = class {
2231
2606
  if (this.retryTimeout) {
2232
2607
  clearTimeout(this.retryTimeout);
2233
2608
  }
2609
+ if (typeof window !== "undefined") {
2610
+ window.removeEventListener("online", this.handleOnline);
2611
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
2612
+ }
2234
2613
  }
2235
2614
  };
2236
2615