edmaxlabs-core 2.5.6 → 2.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -359,20 +359,6 @@ var DocumentSnapshot = class _DocumentSnapshot {
359
359
  }
360
360
  };
361
361
 
362
- // src/utils/documentNomalizer.ts
363
- function normalizePayload(payload) {
364
- const raw = payload?.document ?? payload?.data ?? payload;
365
- if (!raw)
366
- return null;
367
- const doc = { ...raw };
368
- doc.id = payload.id ?? payload._id;
369
- delete doc._id;
370
- return {
371
- change: payload?.change ?? raw?.change ?? null,
372
- data: doc
373
- };
374
- }
375
-
376
362
  // src/database/DocumentRef.ts
377
363
  function validateDocumentData(data, operation) {
378
364
  if (data === null || data === void 0) {
@@ -398,40 +384,22 @@ var DocumentRef = class {
398
384
  this.syncEngine = app.offline().syncEngine;
399
385
  this.localStore = app.offline().localStore;
400
386
  }
401
- // ====================== GET ======================
402
387
  async get() {
403
388
  if (this.persistence) {
404
389
  try {
405
390
  const localSnap = await this.persistence.getDocSnapshot(this.collection, this.id);
391
+ if (typeof navigator === "undefined" || navigator.onLine) {
392
+ this.refreshFromRemote().catch(() => {
393
+ });
394
+ }
406
395
  if (localSnap)
407
396
  return localSnap;
408
397
  } catch (error) {
409
398
  console.error("[EdmaxLabs] Cache read error:", error);
410
399
  }
411
400
  }
412
- try {
413
- const res = await new HttpsRequest({
414
- method: "POST" /* POST */,
415
- endpoint: `${this.app.getBaseUrl()}/db/read`,
416
- headers: {
417
- authorization: this.app.getConfig().token,
418
- "x-project": this.app.getConfig().project
419
- },
420
- body: {
421
- collection: this.collection,
422
- id: this.id,
423
- single: true
424
- }
425
- }).sendRequest();
426
- if (!res?.success || !res.document)
427
- return null;
428
- return DocumentSnapshot.fromMap(res.document);
429
- } catch (error) {
430
- console.error(`[DocumentRef] get(${this.collection}/${this.id}) failed:`, error);
431
- return null;
432
- }
401
+ return this.fetchRemoteSnapshot();
433
402
  }
434
- // ====================== UPDATE ======================
435
403
  async update(data) {
436
404
  if (this._isUpdating) {
437
405
  console.warn(`[DocumentRef] update recursion blocked on ${this.collection}/${this.id}`);
@@ -477,15 +445,13 @@ var DocumentRef = class {
477
445
  baseRevision: old.revision
478
446
  });
479
447
  const snap = DocumentSnapshot.fromMap(updated.data);
480
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
481
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "update");
448
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "update").catch(console.error);
482
449
  this.syncEngine?.flush().catch(console.error);
483
450
  return snap;
484
451
  } finally {
485
452
  this._isUpdating = false;
486
453
  }
487
454
  }
488
- // ====================== SET ======================
489
455
  async set(data) {
490
456
  validateDocumentData(data, "DocumentRef.set");
491
457
  if (!this.persistence) {
@@ -500,6 +466,8 @@ var DocumentRef = class {
500
466
  }).sendRequest();
501
467
  return res?.success ? DocumentSnapshot.fromMap({ ...data, id: this.id }) : null;
502
468
  }
469
+ const existing = await this.persistence.getDoc(this.collection, this.id);
470
+ const mutationType = existing?.exists && !existing.deleted ? "update" : "insert";
503
471
  const updated = await this.persistence.upsertDoc({
504
472
  collection: this.collection,
505
473
  id: this.id,
@@ -508,22 +476,27 @@ var DocumentRef = class {
508
476
  deleted: false,
509
477
  pending: 1,
510
478
  localOnly: false,
511
- status: "pending"
479
+ status: "pending",
480
+ revision: existing?.revision,
481
+ lastSyncedAt: existing?.lastSyncedAt
512
482
  });
513
483
  await this.persistence.enqueueMutation({
514
484
  mutationId: this.persistence.createMutationId(),
515
485
  collection: this.collection,
516
486
  documentId: this.id,
517
- type: "insert",
518
- payload: data
487
+ type: mutationType,
488
+ payload: data,
489
+ baseRevision: existing?.revision
519
490
  });
520
491
  const snap = DocumentSnapshot.fromMap(updated.data);
521
- this.localStore?.emitDocument(this.collection, this.id, snap, "update");
522
- 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);
523
497
  this.syncEngine?.flush().catch(console.error);
524
498
  return snap;
525
499
  }
526
- // ====================== DELETE ======================
527
500
  async delete() {
528
501
  if (this.persistence) {
529
502
  await this.persistence.markDeleted(this.collection, this.id, 1);
@@ -534,8 +507,7 @@ var DocumentRef = class {
534
507
  type: "delete",
535
508
  payload: null
536
509
  });
537
- this.localStore?.emitDocument(this.collection, this.id, null, "empty");
538
- this.localStore?.notifyCollectionChanged(this.collection, this.id, "delete");
510
+ this.app.offline().realtimeBridge?.publishLocalChange(this.collection, this.id, "delete").catch(console.error);
539
511
  this.syncEngine?.flush().catch(console.error);
540
512
  return true;
541
513
  }
@@ -550,20 +522,135 @@ var DocumentRef = class {
550
522
  }).sendRequest();
551
523
  return !!res?.success;
552
524
  }
553
- // ====================== SNAPSHOT ======================
554
525
  onSnapshot(callback) {
555
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
556
- this.app.offline().realtimeBridge?.watchDocument(this.collection, this.id);
557
- return this.localStore.subscribeToDocument(this.collection, this.id, callback) || (() => {
558
- });
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
+ };
559
535
  }
560
- return this.app.rtdb().subscribeToDocumentRaw(this.collection, this.id, (snapshot, change) => {
561
- const res = normalizePayload(snapshot);
562
- 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);
563
543
  });
564
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
+ }
565
579
  };
566
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
+
567
654
  // src/database/Query.ts
568
655
  var Query = class {
569
656
  constructor(app, collection) {
@@ -576,6 +663,84 @@ var Query = class {
576
663
  this.filter.push(expression);
577
664
  return this;
578
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
+ }
579
744
  buildFilter() {
580
745
  const mongoFilter = {};
581
746
  this.filter.forEach(({ key, op, value }) => {
@@ -646,9 +811,7 @@ var Query = class {
646
811
  if (this.filter.length === 0)
647
812
  return docs;
648
813
  return docs.filter(
649
- (snapshot) => this.filter.every(
650
- (expression) => this.matchesFilter(snapshot.data, expression)
651
- )
814
+ (snapshot) => this.filter.every((expression) => this.matchesFilter(snapshot.data, expression))
652
815
  );
653
816
  }
654
817
  async getLocalFilteredSnapshots() {
@@ -658,25 +821,6 @@ var Query = class {
658
821
  const docs = await persistence.getCollectionSnapshots(this.collection);
659
822
  return this.filterDocuments(docs);
660
823
  }
661
- async get() {
662
- const persistence = this.app.offline().persistence;
663
- if (this.filter.length > 0 && persistence) {
664
- const local = await this.getLocalFilteredSnapshots();
665
- if (typeof navigator === "undefined" || !navigator.onLine) {
666
- return local;
667
- }
668
- return this.refreshFromRemote();
669
- }
670
- if (persistence) {
671
- const local = await persistence.getCollectionSnapshots(this.collection);
672
- if (typeof navigator === "undefined" || navigator.onLine) {
673
- this.refreshFromRemote().catch(() => {
674
- });
675
- }
676
- return local;
677
- }
678
- return this.refreshFromRemote();
679
- }
680
824
  async refreshFromRemote() {
681
825
  try {
682
826
  const genfilter = this.buildFilter();
@@ -692,69 +836,90 @@ var Query = class {
692
836
  if (!res?.success || !Array.isArray(res.documents)) {
693
837
  return [];
694
838
  }
695
- return res.documents.map((d) => {
839
+ const snapshots = res.documents.map((d) => {
696
840
  delete d.token;
697
841
  d.id = d.id ?? d._id;
698
842
  delete d._id;
699
843
  return DocumentSnapshot.fromMap(d);
700
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();
701
853
  } catch {
702
854
  return [];
703
855
  }
704
856
  }
705
- // Advanced: query-wide update (keep for now, but document it's server-side only)
706
- async update(data) {
707
- const genfilter = this.buildFilter();
708
- const res = await new HttpsRequest({
709
- method: "POST" /* POST */,
710
- endpoint: `${this.app.getBaseUrl()}/db/update`,
711
- headers: { authorization: this.app.getConfig().token, "x-project": this.app.getConfig().project },
712
- body: {
713
- collection: this.collection,
714
- filter: genfilter,
715
- data
716
- }
717
- }).sendRequest();
718
- 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
+ );
719
874
  }
720
- onSnapshot(callback) {
721
- const genfilter = this.buildFilter();
722
- const persistence = this.app.offline().persistence;
723
- if (persistence) {
724
- const remoteUnsub = this.app.offline().realtimeBridge?.watchCollection(this.collection, genfilter);
725
- if (this.filter.length > 0) {
726
- const emitFiltered = async (change, changedDocId) => {
727
- const docs = await this.getLocalFilteredSnapshots();
728
- callback(docs, change, changedDocId);
729
- };
730
- emitFiltered("insert").catch(console.error);
731
- const localUnsub2 = this.localStore?.subscribeToCollection(
732
- this.collection,
733
- async (snapshots, change, changedDocId) => {
734
- if (snapshots.length > 0) {
735
- callback(this.filterDocuments(snapshots), change, changedDocId);
736
- } else {
737
- await emitFiltered(change, changedDocId);
738
- }
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);
739
905
  }
740
- ) ?? (() => {
741
906
  });
742
- return () => {
743
- localUnsub2();
744
- remoteUnsub?.();
745
- };
746
- }
747
- 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
748
921
  });
749
- return () => {
750
- localUnsub();
751
- remoteUnsub?.();
752
- };
753
- }
754
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
755
- const res = normalizePayload(payload);
756
- callback([DocumentSnapshot.fromMap(res?.data)], changes);
757
- }, genfilter);
922
+ });
758
923
  }
759
924
  };
760
925
 
@@ -786,6 +951,89 @@ var CollectionRef = class {
786
951
  }
787
952
  return local;
788
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() {
789
1037
  try {
790
1038
  const res = await new HttpsRequest({
791
1039
  method: "POST" /* POST */,
@@ -802,18 +1050,25 @@ var CollectionRef = class {
802
1050
  if (!res?.success || !Array.isArray(res.documents)) {
803
1051
  return [];
804
1052
  }
805
- return res.documents.map((d) => {
806
- delete d.token;
807
- d.id = d.id ?? d._id;
808
- delete d._id;
809
- 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;
810
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));
811
1066
  } catch (error) {
812
- console.error("[EdmaxLabs] Collection get failed:", error);
1067
+ console.error("[EdmaxLabs] refreshFromRemote failed:", error);
813
1068
  return [];
814
1069
  }
815
1070
  }
816
- async refreshFromRemote() {
1071
+ async fetchRemoteSnapshots() {
817
1072
  try {
818
1073
  const res = await new HttpsRequest({
819
1074
  method: "POST" /* POST */,
@@ -830,17 +1085,6 @@ var CollectionRef = class {
830
1085
  if (!res?.success || !Array.isArray(res.documents)) {
831
1086
  return [];
832
1087
  }
833
- for (const raw of res.documents) {
834
- const doc = { ...raw };
835
- doc.id = doc.id ?? doc._id;
836
- delete doc._id;
837
- if (this.persistence) {
838
- await this.persistence.applyRemoteDoc(this.collection, doc);
839
- }
840
- }
841
- if (this.persistence) {
842
- return await this.persistence.getCollectionSnapshots(this.collection);
843
- }
844
1088
  return res.documents.map((d) => {
845
1089
  delete d.token;
846
1090
  d.id = d.id ?? d._id;
@@ -848,59 +1092,68 @@ var CollectionRef = class {
848
1092
  return DocumentSnapshot.fromMap(d);
849
1093
  });
850
1094
  } catch (error) {
851
- console.error("[EdmaxLabs] refreshFromRemote failed:", error);
1095
+ console.error("[EdmaxLabs] Collection get failed:", error);
852
1096
  return [];
853
1097
  }
854
1098
  }
855
- async add(data) {
856
- if (this.persistence) {
857
- const localId = this.persistence.createLocalId();
858
- const docRecord = await this.persistence.upsertDoc({
859
- collection: this.collection,
860
- id: localId,
861
- data: { ...data, id: localId },
862
- exists: true,
863
- deleted: false,
864
- pending: 1,
865
- localOnly: true,
866
- status: "pending"
867
- });
868
- await this.persistence.enqueueMutation({
869
- mutationId: this.persistence.createMutationId(),
870
- collection: this.collection,
871
- documentId: localId,
872
- type: "insert",
873
- payload: docRecord.data
874
- });
875
- const snap = DocumentSnapshot.fromMap(docRecord.data);
876
- this.localStore?.emitDocument(this.collection, localId, snap, "insert");
877
- this.localStore?.notifyCollectionChanged(this.collection, localId, "insert");
878
- this.syncEngine?.flush().catch(console.error);
879
- return snap;
880
- }
881
- const res = await new HttpsRequest({
882
- method: "POST" /* POST */,
883
- endpoint: `${this.app.getBaseUrl()}/db/create`,
884
- headers: {
885
- authorization: this.app.getConfig().token,
886
- "x-project": this.app.getConfig().project
887
- },
888
- body: { collection: this.collection, data: { ...data, id: "" } }
889
- // server will generate id
890
- }).sendRequest();
891
- if (!res?.success || !res.document)
892
- return null;
893
- 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
+ });
894
1111
  }
895
- onSnapshot(callback) {
896
- if (this.persistence && this.localStore && this.app.offline().realtimeBridge) {
897
- this.app.offline().realtimeBridge?.watchCollection(this.collection);
898
- 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
+ }
899
1141
  });
900
- }
901
- return this.app.rtdb().subscribeToCollectionRaw(this.collection, (payload, changes) => {
902
- const res = normalizePayload(payload);
903
- 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);
904
1157
  });
905
1158
  }
906
1159
  };
@@ -950,6 +1203,7 @@ var Batch = class {
950
1203
  const persistence = this.app.offline().persistence;
951
1204
  const localStore = this.app.offline().localStore;
952
1205
  const syncEngine = this.app.offline().syncEngine;
1206
+ const realtimeBridge = this.app.offline().realtimeBridge;
953
1207
  if (persistence && localStore) {
954
1208
  const results = [];
955
1209
  for (const op of this.ops) {
@@ -978,8 +1232,7 @@ var Batch = class {
978
1232
  payload: op.data
979
1233
  });
980
1234
  const snap = DocumentSnapshot.fromMap(upserted.data);
981
- localStore.emitDocument(op.collection, op.id, snap, "insert");
982
- localStore.notifyCollectionChanged(op.collection, op.id, "insert");
1235
+ realtimeBridge?.publishLocalChange(op.collection, op.id, "insert").catch(console.error);
983
1236
  results.push(snap);
984
1237
  } else if (op.op === "delete") {
985
1238
  const docRef = new DocumentRef(this.app, op.collection, op.id);
@@ -1091,6 +1344,20 @@ function generateUUID() {
1091
1344
  });
1092
1345
  }
1093
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
+
1094
1361
  // src/database/Realtime.ts
1095
1362
  var Realtime = class {
1096
1363
  constructor(app) {
@@ -1159,8 +1426,12 @@ var Realtime = class {
1159
1426
  const channel = `${lid}-${event}`;
1160
1427
  const fn = (payload) => {
1161
1428
  const normalized = normalizePayload(payload);
1162
- if (!normalized)
1429
+ if (!normalized) {
1430
+ if (event === "delete") {
1431
+ callback(payload, "delete");
1432
+ }
1163
1433
  return;
1434
+ }
1164
1435
  callback(normalized.data, event);
1165
1436
  };
1166
1437
  this.socket.on(channel, fn);
@@ -1284,11 +1555,11 @@ var LocalStore = class {
1284
1555
  constructor() {
1285
1556
  this.documentListeners = /* @__PURE__ */ new Map();
1286
1557
  this.collectionListeners = /* @__PURE__ */ new Map();
1558
+ this.childListeners = /* @__PURE__ */ new Map();
1287
1559
  }
1288
1560
  docKey(collection, id) {
1289
1561
  return `${collection}:${id}`;
1290
1562
  }
1291
- // ===================== DOCUMENT LISTENERS =====================
1292
1563
  subscribeToDocument(collection, id, callback) {
1293
1564
  const key = this.docKey(collection, id);
1294
1565
  if (!this.documentListeners.has(key)) {
@@ -1303,24 +1574,33 @@ var LocalStore = class {
1303
1574
  }
1304
1575
  };
1305
1576
  }
1306
- // ===================== COLLECTION LISTENERS =====================
1307
- subscribeToCollection(collection, callback) {
1308
- if (!this.collectionListeners.has(collection)) {
1309
- 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());
1310
1580
  }
1311
- const listeners = this.collectionListeners.get(collection);
1581
+ const listeners = this.collectionListeners.get(targetKey);
1312
1582
  listeners.add(callback);
1313
1583
  return () => {
1314
1584
  listeners.delete(callback);
1315
1585
  if (listeners.size === 0) {
1316
- 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);
1317
1601
  }
1318
1602
  };
1319
1603
  }
1320
- // ===================== EMITTERS =====================
1321
- /**
1322
- * Notify all listeners for a specific document
1323
- */
1324
1604
  emitDocument(collection, id, snapshot, change) {
1325
1605
  const key = this.docKey(collection, id);
1326
1606
  this.documentListeners.get(key)?.forEach((cb) => {
@@ -1331,64 +1611,66 @@ var LocalStore = class {
1331
1611
  }
1332
1612
  });
1333
1613
  }
1334
- /**
1335
- * Notify all listeners for a collection.
1336
- * This is the most important fix — collection listeners now receive the full list.
1337
- */
1338
- emitCollection(collection, snapshots, change, changedDocId) {
1339
- this.collectionListeners.get(collection)?.forEach((cb) => {
1614
+ emitCollection(targetKey, emission) {
1615
+ this.collectionListeners.get(targetKey)?.forEach((cb) => {
1340
1616
  try {
1341
- cb(snapshots, change, changedDocId);
1617
+ cb(emission.snapshots, emission.source, emission.changedDocId);
1342
1618
  } catch (err) {
1343
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1619
+ console.error(`[EdmaxLabs] Error in collection listener for ${targetKey}:`, err);
1344
1620
  }
1345
1621
  });
1346
- }
1347
- /**
1348
- * OPTIMIZED: Notify collection listeners that something changed, without fetching full collection.
1349
- * Listeners can call getCollectionSnapshots() themselves if they need the full list.
1350
- * This avoids expensive collection queries after every single mutation.
1351
- */
1352
- notifyCollectionChanged(collection, changedDocId, change) {
1353
- this.collectionListeners.get(collection)?.forEach((cb) => {
1354
- try {
1355
- cb([], change, changedDocId);
1356
- } catch (err) {
1357
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1358
- }
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
+ });
1359
1629
  });
1360
1630
  }
1361
- // ===================== UTILITY =====================
1362
- /**
1363
- * Clear all listeners (useful for testing or when persistence is disabled)
1364
- */
1365
1631
  clearAllListeners() {
1366
1632
  this.documentListeners.clear();
1367
1633
  this.collectionListeners.clear();
1634
+ this.childListeners.clear();
1368
1635
  }
1369
- /**
1370
- * Get current listener count (for debugging / dev tools)
1371
- */
1372
1636
  get listenerCount() {
1373
1637
  let docCount = 0;
1374
1638
  this.documentListeners.forEach((set) => docCount += set.size);
1375
1639
  let collCount = 0;
1376
1640
  this.collectionListeners.forEach((set) => collCount += set.size);
1641
+ this.childListeners.forEach((set) => collCount += set.size);
1377
1642
  return { documents: docCount, collections: collCount };
1378
1643
  }
1379
- /**
1380
- * Remove all listeners for a specific collection (useful for cleanup)
1381
- */
1382
- removeCollectionListeners(collection) {
1383
- this.collectionListeners.delete(collection);
1644
+ removeCollectionListeners(targetKey) {
1645
+ this.collectionListeners.delete(targetKey);
1646
+ this.childListeners.delete(targetKey);
1384
1647
  }
1385
- /**
1386
- * Remove all listeners for a specific document (useful for cleanup)
1387
- */
1388
1648
  removeDocumentListeners(collection, id) {
1389
1649
  const key = this.docKey(collection, id);
1390
1650
  this.documentListeners.delete(key);
1391
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
+ }
1392
1674
  };
1393
1675
 
1394
1676
  // ../../../../node_modules/idb/build/wrap-idb-value.js
@@ -1759,11 +2041,25 @@ var Persistence = class {
1759
2041
  }
1760
2042
  async getPendingMutations() {
1761
2043
  const app = await this.getDb();
1762
- const [pending, failed] = await Promise.all([
1763
- app.getAllFromIndex("mutations", "by-status", "pending"),
1764
- app.getAllFromIndex("mutations", "by-status", "failed")
1765
- ]);
1766
- 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;
1767
2063
  }
1768
2064
  async getMutation(mutationId) {
1769
2065
  const app = await this.getDb();
@@ -1868,6 +2164,27 @@ var Persistence = class {
1868
2164
  }
1869
2165
  return this.markDeleted(collection, id, 0);
1870
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
+ }
1871
2188
  // ==================== UTILITIES ====================
1872
2189
  createLocalId() {
1873
2190
  return `local_${crypto.randomUUID()}`;
@@ -1897,112 +2214,169 @@ var RealtimeBridge = class {
1897
2214
  this.app = app;
1898
2215
  this.persistence = persistence;
1899
2216
  this.store = store;
1900
- this.collectionUnsubs = /* @__PURE__ */ new Map();
1901
- this.documentUnsubs = /* @__PURE__ */ new Map();
2217
+ this.collectionTargets = /* @__PURE__ */ new Map();
2218
+ this.documentTargets = /* @__PURE__ */ new Map();
1902
2219
  }
1903
2220
  docKey(collection, id) {
1904
2221
  return `${collection}:${id}`;
1905
2222
  }
1906
- // ===================== PUBLIC API =====================
1907
- watchCollection(collection, filter = {}) {
1908
- const key = `${collection}:${JSON.stringify(filter)}`;
1909
- if (this.collectionUnsubs.has(key)) {
1910
- 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);
1911
2229
  }
1912
- this.emitCurrentCollection(collection);
1913
2230
  const unsub = this.app.rtdb().subscribeToCollectionRaw(
1914
2231
  collection,
1915
2232
  async (payload, change) => {
1916
2233
  if (change === "delete") {
1917
2234
  const id = payload?.id || payload?._id;
1918
- if (id)
2235
+ if (id) {
1919
2236
  await this.handleRemoteDelete(collection, id);
1920
- } else {
1921
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2237
+ }
2238
+ return;
1922
2239
  }
2240
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1923
2241
  },
1924
2242
  filter
1925
2243
  );
1926
- const fullUnsub = () => {
1927
- unsub();
1928
- this.collectionUnsubs.delete(key);
1929
- };
1930
- this.collectionUnsubs.set(key, fullUnsub);
1931
- 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);
1932
2254
  }
1933
2255
  watchDocument(collection, id) {
1934
2256
  const key = this.docKey(collection, id);
1935
- if (this.documentUnsubs.has(key)) {
1936
- return this.documentUnsubs.get(key);
2257
+ const existing = this.documentTargets.get(key);
2258
+ if (existing) {
2259
+ existing.refCount += 1;
2260
+ return () => this.releaseDocument(key);
1937
2261
  }
1938
- this.emitCurrentDocument(collection, id);
1939
2262
  const unsub = this.app.rtdb().subscribeToDocumentRaw(
1940
2263
  collection,
1941
2264
  id,
1942
2265
  async (payload, change) => {
1943
2266
  if (change === "delete") {
1944
2267
  await this.handleRemoteDelete(collection, id);
1945
- } else {
1946
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2268
+ return;
1947
2269
  }
2270
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1948
2271
  }
1949
2272
  );
1950
- const fullUnsub = () => {
1951
- unsub();
1952
- this.documentUnsubs.delete(key);
1953
- };
1954
- this.documentUnsubs.set(key, fullUnsub);
1955
- 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;
1956
2295
  }
1957
- // ===================== INTERNAL HANDLERS =====================
1958
2296
  async emitCurrentDocument(collection, id) {
1959
- const localDoc = await this.persistence.getDoc(collection, id);
1960
- const snapshot = localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
1961
- 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;
1962
2300
  }
1963
- async emitCurrentCollection(collection) {
1964
- const localDocs = await this.persistence.getCollectionSnapshots(collection);
1965
- 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();
1966
2319
  }
1967
- async handleRemoteCreateOrUpdate(collection, raw, change) {
2320
+ async handleRemoteCreateOrUpdate(collection, raw, source) {
1968
2321
  const id = raw.id ?? raw._id;
1969
2322
  if (!id)
1970
2323
  return;
1971
2324
  const saved = await this.persistence.applyRemoteDoc(collection, raw);
1972
- if (!saved)
1973
- return;
1974
- const snap = DocumentSnapshot.fromMap(saved.data);
1975
- this.store.emitDocument(collection, id, snap, change);
1976
- 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);
1977
2330
  }
1978
2331
  async handleRemoteDelete(collection, id) {
1979
2332
  await this.persistence.applyRemoteDelete(collection, id);
1980
2333
  this.store.emitDocument(collection, id, null, "delete");
1981
- this.store.notifyCollectionChanged(collection, id, "delete");
2334
+ await this.refreshCollectionTargets(collection, "delete", id, false);
1982
2335
  }
1983
- // ===================== LIFECYCLE =====================
1984
- /**
1985
- * Call this when Socket.IO reconnects or when going online
1986
- * Replays pending mutations + refreshes all active subscriptions from cache
1987
- */
1988
- async onReconnect() {
1989
- for (const [key] of this.collectionUnsubs) {
1990
- const collection = key.split(":")[0];
1991
- await this.emitCurrentCollection(collection);
1992
- }
1993
- for (const [key] of this.documentUnsubs) {
1994
- const [collection, id] = key.split(":");
1995
- await this.emitCurrentDocument(collection, id);
1996
- }
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;
1997
2339
  }
1998
- /**
1999
- * Clean up all subscriptions (call from EdmaxLabs destructor if needed)
2000
- */
2001
- dispose() {
2002
- this.collectionUnsubs.forEach((unsub) => unsub());
2003
- this.documentUnsubs.forEach((unsub) => unsub());
2004
- this.collectionUnsubs.clear();
2005
- 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);
2006
2380
  }
2007
2381
  };
2008
2382
 
@@ -2014,16 +2388,19 @@ var SyncEngine = class {
2014
2388
  this.retryTimeout = null;
2015
2389
  this.MAX_RETRIES = 5;
2016
2390
  this.BASE_RETRY_DELAY = 2e3;
2391
+ this.handleOnline = () => this.onNetworkOnline();
2392
+ this.handleVisibilityChange = () => {
2393
+ if (document.visibilityState === "visible")
2394
+ this.onNetworkOnline();
2395
+ };
2017
2396
  this.app = app;
2018
2397
  this.persistence = persistence;
2019
2398
  this.store = store;
2020
2399
  this.realtimeBridge = realtimeBridge || null;
2400
+ this.persistence.recoverSyncingMutations().catch(console.error);
2021
2401
  if (typeof window !== "undefined") {
2022
- window.addEventListener("online", () => this.onNetworkOnline());
2023
- document.addEventListener("visibilitychange", () => {
2024
- if (document.visibilityState === "visible")
2025
- this.onNetworkOnline();
2026
- });
2402
+ window.addEventListener("online", this.handleOnline);
2403
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
2027
2404
  }
2028
2405
  }
2029
2406
  onNetworkOnline() {
@@ -2040,6 +2417,7 @@ var SyncEngine = class {
2040
2417
  return;
2041
2418
  this.syncing = true;
2042
2419
  try {
2420
+ await this.persistence.recoverSyncingMutations();
2043
2421
  const pending = await this.persistence.getPendingMutations();
2044
2422
  for (const mutation of pending) {
2045
2423
  if (mutation.retryCount >= this.MAX_RETRIES) {
@@ -2116,8 +2494,7 @@ var SyncEngine = class {
2116
2494
  if (replaced) {
2117
2495
  const snap = DocumentSnapshot.fromMap(replaced.data);
2118
2496
  this.store.emitDocument(mutation.collection, oldId, snap, "insert");
2119
- this.store.emitDocument(mutation.collection, newId, snap, "insert");
2120
- this.store.notifyCollectionChanged(mutation.collection, newId, "insert");
2497
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, newId, "insert");
2121
2498
  }
2122
2499
  return true;
2123
2500
  }
@@ -2150,7 +2527,7 @@ var SyncEngine = class {
2150
2527
  });
2151
2528
  const snap = DocumentSnapshot.fromMap(local.data);
2152
2529
  this.store.emitDocument(mutation.collection, mutation.documentId, snap, "update");
2153
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "update");
2530
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "update");
2154
2531
  return true;
2155
2532
  }
2156
2533
  async syncDelete(mutation) {
@@ -2167,7 +2544,7 @@ var SyncEngine = class {
2167
2544
  return false;
2168
2545
  await this.persistence.markDeleted(mutation.collection, mutation.documentId, 0);
2169
2546
  this.store.emitDocument(mutation.collection, mutation.documentId, null, "delete");
2170
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "delete");
2547
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "delete");
2171
2548
  return true;
2172
2549
  }
2173
2550
  scheduleRetry() {
@@ -2187,8 +2564,7 @@ var SyncEngine = class {
2187
2564
  * Returns mutations that exceeded MAX_RETRIES
2188
2565
  */
2189
2566
  async getFailedMutations() {
2190
- const all = await this.persistence.getPendingMutations();
2191
- return all.filter((m) => m.status === "failed");
2567
+ return this.persistence.getFailedMutations();
2192
2568
  }
2193
2569
  /**
2194
2570
  * Retry a specific failed mutation by resetting its retry count
@@ -2230,6 +2606,10 @@ var SyncEngine = class {
2230
2606
  if (this.retryTimeout) {
2231
2607
  clearTimeout(this.retryTimeout);
2232
2608
  }
2609
+ if (typeof window !== "undefined") {
2610
+ window.removeEventListener("online", this.handleOnline);
2611
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
2612
+ }
2233
2613
  }
2234
2614
  };
2235
2615