edmaxlabs-core 2.5.6 → 2.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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);
@@ -1079,16 +1332,18 @@ var Database = class {
1079
1332
  // src/database/Realtime.ts
1080
1333
  import { io } from "socket.io-client";
1081
1334
 
1082
- // src/utils/uuid.ts
1083
- function generateUUID() {
1084
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
1085
- return crypto.randomUUID();
1086
- }
1087
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
1088
- const r = Math.random() * 16 | 0;
1089
- const v = c === "x" ? r : r & 3 | 8;
1090
- return v.toString(16);
1091
- });
1335
+ // src/utils/documentNomalizer.ts
1336
+ function normalizePayload(payload) {
1337
+ const raw = payload?.document ?? payload?.data ?? payload;
1338
+ if (!raw)
1339
+ return null;
1340
+ const doc = { ...raw };
1341
+ doc.id = doc.id ?? raw?.id ?? raw?._id ?? payload?.id ?? payload?._id;
1342
+ delete doc._id;
1343
+ return {
1344
+ change: payload?.change ?? raw?.change ?? null,
1345
+ data: doc
1346
+ };
1092
1347
  }
1093
1348
 
1094
1349
  // src/database/Realtime.ts
@@ -1152,15 +1407,19 @@ var Realtime = class {
1152
1407
  */
1153
1408
  subscribeToCollectionRaw(collection, callback, filter = {}) {
1154
1409
  this.connect();
1155
- const lid = `col_${collection}_${generateUUID()}`;
1410
+ const lid = collection;
1156
1411
  const handlers = [];
1157
- this.socket.emit("subscribe", { collection, filter, lid });
1412
+ this.socket.emit("subscribe", { collection, filter });
1158
1413
  this.events.forEach((event) => {
1159
1414
  const channel = `${lid}-${event}`;
1160
1415
  const fn = (payload) => {
1161
1416
  const normalized = normalizePayload(payload);
1162
- if (!normalized)
1417
+ if (!normalized) {
1418
+ if (event === "delete") {
1419
+ callback(payload, "delete");
1420
+ }
1163
1421
  return;
1422
+ }
1164
1423
  callback(normalized.data, event);
1165
1424
  };
1166
1425
  this.socket.on(channel, fn);
@@ -1174,10 +1433,10 @@ var Realtime = class {
1174
1433
  */
1175
1434
  subscribeToDocumentRaw(collection, id, callback) {
1176
1435
  this.connect();
1177
- const lid = `doc_${collection}_${id}_${generateUUID()}`;
1436
+ const lid = collection;
1178
1437
  const filter = { _id: id };
1179
1438
  const handlers = [];
1180
- this.socket.emit("subscribe", { collection, filter, lid });
1439
+ this.socket.emit("subscribe", { collection, filter });
1181
1440
  this.events.forEach((event) => {
1182
1441
  const channel = `${lid}-${event}`;
1183
1442
  const fn = (payload) => {
@@ -1284,11 +1543,11 @@ var LocalStore = class {
1284
1543
  constructor() {
1285
1544
  this.documentListeners = /* @__PURE__ */ new Map();
1286
1545
  this.collectionListeners = /* @__PURE__ */ new Map();
1546
+ this.childListeners = /* @__PURE__ */ new Map();
1287
1547
  }
1288
1548
  docKey(collection, id) {
1289
1549
  return `${collection}:${id}`;
1290
1550
  }
1291
- // ===================== DOCUMENT LISTENERS =====================
1292
1551
  subscribeToDocument(collection, id, callback) {
1293
1552
  const key = this.docKey(collection, id);
1294
1553
  if (!this.documentListeners.has(key)) {
@@ -1303,24 +1562,33 @@ var LocalStore = class {
1303
1562
  }
1304
1563
  };
1305
1564
  }
1306
- // ===================== COLLECTION LISTENERS =====================
1307
- subscribeToCollection(collection, callback) {
1308
- if (!this.collectionListeners.has(collection)) {
1309
- this.collectionListeners.set(collection, /* @__PURE__ */ new Set());
1565
+ subscribeToCollection(targetKey, callback) {
1566
+ if (!this.collectionListeners.has(targetKey)) {
1567
+ this.collectionListeners.set(targetKey, /* @__PURE__ */ new Set());
1310
1568
  }
1311
- const listeners = this.collectionListeners.get(collection);
1569
+ const listeners = this.collectionListeners.get(targetKey);
1312
1570
  listeners.add(callback);
1313
1571
  return () => {
1314
1572
  listeners.delete(callback);
1315
1573
  if (listeners.size === 0) {
1316
- this.collectionListeners.delete(collection);
1574
+ this.collectionListeners.delete(targetKey);
1575
+ this.childListeners.delete(targetKey);
1576
+ }
1577
+ };
1578
+ }
1579
+ subscribeToChildEvents(targetKey, callbacks) {
1580
+ if (!this.childListeners.has(targetKey)) {
1581
+ this.childListeners.set(targetKey, /* @__PURE__ */ new Set());
1582
+ }
1583
+ const listeners = this.childListeners.get(targetKey);
1584
+ listeners.add(callbacks);
1585
+ return () => {
1586
+ listeners.delete(callbacks);
1587
+ if (listeners.size === 0) {
1588
+ this.childListeners.delete(targetKey);
1317
1589
  }
1318
1590
  };
1319
1591
  }
1320
- // ===================== EMITTERS =====================
1321
- /**
1322
- * Notify all listeners for a specific document
1323
- */
1324
1592
  emitDocument(collection, id, snapshot, change) {
1325
1593
  const key = this.docKey(collection, id);
1326
1594
  this.documentListeners.get(key)?.forEach((cb) => {
@@ -1331,64 +1599,66 @@ var LocalStore = class {
1331
1599
  }
1332
1600
  });
1333
1601
  }
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) => {
1602
+ emitCollection(targetKey, emission) {
1603
+ this.collectionListeners.get(targetKey)?.forEach((cb) => {
1340
1604
  try {
1341
- cb(snapshots, change, changedDocId);
1605
+ cb(emission.snapshots, emission.source, emission.changedDocId);
1342
1606
  } catch (err) {
1343
- console.error(`[EdmaxLabs] Error in collection listener for ${collection}:`, err);
1607
+ console.error(`[EdmaxLabs] Error in collection listener for ${targetKey}:`, err);
1344
1608
  }
1345
1609
  });
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
- }
1610
+ const childChanges = emission.childChanges ?? [];
1611
+ if (childChanges.length === 0)
1612
+ return;
1613
+ this.childListeners.get(targetKey)?.forEach((callbacks) => {
1614
+ childChanges.forEach((change) => {
1615
+ this.dispatchChildEvent(callbacks, change, emission);
1616
+ });
1359
1617
  });
1360
1618
  }
1361
- // ===================== UTILITY =====================
1362
- /**
1363
- * Clear all listeners (useful for testing or when persistence is disabled)
1364
- */
1365
1619
  clearAllListeners() {
1366
1620
  this.documentListeners.clear();
1367
1621
  this.collectionListeners.clear();
1622
+ this.childListeners.clear();
1368
1623
  }
1369
- /**
1370
- * Get current listener count (for debugging / dev tools)
1371
- */
1372
1624
  get listenerCount() {
1373
1625
  let docCount = 0;
1374
1626
  this.documentListeners.forEach((set) => docCount += set.size);
1375
1627
  let collCount = 0;
1376
1628
  this.collectionListeners.forEach((set) => collCount += set.size);
1629
+ this.childListeners.forEach((set) => collCount += set.size);
1377
1630
  return { documents: docCount, collections: collCount };
1378
1631
  }
1379
- /**
1380
- * Remove all listeners for a specific collection (useful for cleanup)
1381
- */
1382
- removeCollectionListeners(collection) {
1383
- this.collectionListeners.delete(collection);
1632
+ removeCollectionListeners(targetKey) {
1633
+ this.collectionListeners.delete(targetKey);
1634
+ this.childListeners.delete(targetKey);
1384
1635
  }
1385
- /**
1386
- * Remove all listeners for a specific document (useful for cleanup)
1387
- */
1388
1636
  removeDocumentListeners(collection, id) {
1389
1637
  const key = this.docKey(collection, id);
1390
1638
  this.documentListeners.delete(key);
1391
1639
  }
1640
+ dispatchChildEvent(callbacks, change, emission) {
1641
+ const context = {
1642
+ snapshots: emission.snapshots,
1643
+ source: emission.source,
1644
+ changedDocId: emission.changedDocId,
1645
+ fromCache: emission.fromCache ?? true,
1646
+ oldIndex: change.oldIndex,
1647
+ newIndex: change.newIndex,
1648
+ previousDoc: change.previousDoc
1649
+ };
1650
+ try {
1651
+ if (change.type === "added") {
1652
+ callbacks.onChildAdded?.(change.doc, context);
1653
+ } else if (change.type === "modified") {
1654
+ callbacks.onChildUpdated?.(change.doc, context);
1655
+ } else if (change.type === "removed") {
1656
+ callbacks.onChildRemoved?.(change.doc, context);
1657
+ }
1658
+ } catch (err) {
1659
+ console.error("[EdmaxLabs] Error in child listener:", err);
1660
+ }
1661
+ }
1392
1662
  };
1393
1663
 
1394
1664
  // ../../../../node_modules/idb/build/wrap-idb-value.js
@@ -1759,11 +2029,25 @@ var Persistence = class {
1759
2029
  }
1760
2030
  async getPendingMutations() {
1761
2031
  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);
2032
+ const pending = await app.getAllFromIndex("mutations", "by-status", "pending");
2033
+ return pending.sort((a, b) => a.createdAt - b.createdAt);
2034
+ }
2035
+ async getFailedMutations() {
2036
+ const app = await this.getDb();
2037
+ const failed = await app.getAllFromIndex("mutations", "by-status", "failed");
2038
+ return failed.sort((a, b) => a.createdAt - b.createdAt);
2039
+ }
2040
+ async recoverSyncingMutations() {
2041
+ const app = await this.getDb();
2042
+ const syncing = await app.getAllFromIndex("mutations", "by-status", "syncing");
2043
+ for (const mutation of syncing) {
2044
+ await app.put("mutations", {
2045
+ ...mutation,
2046
+ status: "pending",
2047
+ updatedAt: this.now()
2048
+ });
2049
+ }
2050
+ return syncing.length;
1767
2051
  }
1768
2052
  async getMutation(mutationId) {
1769
2053
  const app = await this.getDb();
@@ -1868,6 +2152,27 @@ var Persistence = class {
1868
2152
  }
1869
2153
  return this.markDeleted(collection, id, 0);
1870
2154
  }
2155
+ async reconcileCollectionFromRemote(collection, documents) {
2156
+ const app = await this.getDb();
2157
+ const existing = await app.getAllFromIndex("docs", "by-collection", collection);
2158
+ const incomingIds = /* @__PURE__ */ new Set();
2159
+ for (const data of documents) {
2160
+ const id = data.id ?? data._id;
2161
+ if (!id)
2162
+ continue;
2163
+ incomingIds.add(String(id));
2164
+ await this.applyRemoteDoc(collection, { ...data, id: String(id) });
2165
+ }
2166
+ for (const doc of existing) {
2167
+ if (doc.pending && doc.pending > 0)
2168
+ continue;
2169
+ if (!doc.exists || doc.deleted)
2170
+ continue;
2171
+ if (incomingIds.has(doc.id))
2172
+ continue;
2173
+ await this.markDeleted(collection, doc.id, 0);
2174
+ }
2175
+ }
1871
2176
  // ==================== UTILITIES ====================
1872
2177
  createLocalId() {
1873
2178
  return `local_${crypto.randomUUID()}`;
@@ -1897,112 +2202,169 @@ var RealtimeBridge = class {
1897
2202
  this.app = app;
1898
2203
  this.persistence = persistence;
1899
2204
  this.store = store;
1900
- this.collectionUnsubs = /* @__PURE__ */ new Map();
1901
- this.documentUnsubs = /* @__PURE__ */ new Map();
2205
+ this.collectionTargets = /* @__PURE__ */ new Map();
2206
+ this.documentTargets = /* @__PURE__ */ new Map();
1902
2207
  }
1903
2208
  docKey(collection, id) {
1904
2209
  return `${collection}:${id}`;
1905
2210
  }
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);
2211
+ watchCollection(collection, filter = {}, read) {
2212
+ const targetKey = buildTargetKey(collection, filter);
2213
+ const existing = this.collectionTargets.get(targetKey);
2214
+ if (existing) {
2215
+ existing.refCount += 1;
2216
+ return () => this.releaseCollection(targetKey);
1911
2217
  }
1912
- this.emitCurrentCollection(collection);
1913
2218
  const unsub = this.app.rtdb().subscribeToCollectionRaw(
1914
2219
  collection,
1915
2220
  async (payload, change) => {
1916
2221
  if (change === "delete") {
1917
2222
  const id = payload?.id || payload?._id;
1918
- if (id)
2223
+ if (id) {
1919
2224
  await this.handleRemoteDelete(collection, id);
1920
- } else {
1921
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2225
+ }
2226
+ return;
1922
2227
  }
2228
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1923
2229
  },
1924
2230
  filter
1925
2231
  );
1926
- const fullUnsub = () => {
1927
- unsub();
1928
- this.collectionUnsubs.delete(key);
1929
- };
1930
- this.collectionUnsubs.set(key, fullUnsub);
1931
- return fullUnsub;
2232
+ this.collectionTargets.set(targetKey, {
2233
+ targetKey,
2234
+ collection,
2235
+ filter,
2236
+ read,
2237
+ refCount: 1,
2238
+ unsub,
2239
+ lastSnapshots: []
2240
+ });
2241
+ return () => this.releaseCollection(targetKey);
1932
2242
  }
1933
2243
  watchDocument(collection, id) {
1934
2244
  const key = this.docKey(collection, id);
1935
- if (this.documentUnsubs.has(key)) {
1936
- return this.documentUnsubs.get(key);
2245
+ const existing = this.documentTargets.get(key);
2246
+ if (existing) {
2247
+ existing.refCount += 1;
2248
+ return () => this.releaseDocument(key);
1937
2249
  }
1938
- this.emitCurrentDocument(collection, id);
1939
2250
  const unsub = this.app.rtdb().subscribeToDocumentRaw(
1940
2251
  collection,
1941
2252
  id,
1942
2253
  async (payload, change) => {
1943
2254
  if (change === "delete") {
1944
2255
  await this.handleRemoteDelete(collection, id);
1945
- } else {
1946
- await this.handleRemoteCreateOrUpdate(collection, payload, change);
2256
+ return;
1947
2257
  }
2258
+ await this.handleRemoteCreateOrUpdate(collection, payload, change);
1948
2259
  }
1949
2260
  );
1950
- const fullUnsub = () => {
1951
- unsub();
1952
- this.documentUnsubs.delete(key);
1953
- };
1954
- this.documentUnsubs.set(key, fullUnsub);
1955
- return fullUnsub;
2261
+ this.documentTargets.set(key, {
2262
+ key,
2263
+ collection,
2264
+ id,
2265
+ refCount: 1,
2266
+ unsub
2267
+ });
2268
+ return () => this.releaseDocument(key);
2269
+ }
2270
+ async emitCurrentCollection(collection, filter = {}) {
2271
+ const targetKey = buildTargetKey(collection, filter);
2272
+ const target = this.collectionTargets.get(targetKey);
2273
+ if (!target)
2274
+ return [];
2275
+ return this.refreshCollectionTarget(target, "initial");
2276
+ }
2277
+ primeCollectionTarget(collection, filter, snapshots) {
2278
+ const targetKey = buildTargetKey(collection, filter);
2279
+ const target = this.collectionTargets.get(targetKey);
2280
+ if (!target)
2281
+ return;
2282
+ target.lastSnapshots = snapshots;
1956
2283
  }
1957
- // ===================== INTERNAL HANDLERS =====================
1958
2284
  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");
2285
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2286
+ this.store.emitDocument(collection, id, snapshot, "initial");
2287
+ return snapshot;
1962
2288
  }
1963
- async emitCurrentCollection(collection) {
1964
- const localDocs = await this.persistence.getCollectionSnapshots(collection);
1965
- this.store.emitCollection(collection, localDocs, "insert");
2289
+ async publishLocalChange(collection, id, source) {
2290
+ const snapshot = await this.getCurrentDocumentSnapshot(collection, id);
2291
+ this.store.emitDocument(collection, id, snapshot, source);
2292
+ await this.refreshCollectionTargets(collection, source, id);
2293
+ }
2294
+ async onReconnect() {
2295
+ for (const target of this.collectionTargets.values()) {
2296
+ await this.refreshCollectionTarget(target, "initial");
2297
+ }
2298
+ for (const target of this.documentTargets.values()) {
2299
+ await this.emitCurrentDocument(target.collection, target.id);
2300
+ }
2301
+ }
2302
+ dispose() {
2303
+ this.collectionTargets.forEach((target) => target.unsub());
2304
+ this.documentTargets.forEach((target) => target.unsub());
2305
+ this.collectionTargets.clear();
2306
+ this.documentTargets.clear();
1966
2307
  }
1967
- async handleRemoteCreateOrUpdate(collection, raw, change) {
2308
+ async handleRemoteCreateOrUpdate(collection, raw, source) {
1968
2309
  const id = raw.id ?? raw._id;
1969
2310
  if (!id)
1970
2311
  return;
1971
2312
  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);
2313
+ if (saved) {
2314
+ const snap = DocumentSnapshot.fromMap(saved.data);
2315
+ this.store.emitDocument(collection, id, snap, source);
2316
+ }
2317
+ await this.refreshCollectionTargets(collection, source, id, false);
1977
2318
  }
1978
2319
  async handleRemoteDelete(collection, id) {
1979
2320
  await this.persistence.applyRemoteDelete(collection, id);
1980
2321
  this.store.emitDocument(collection, id, null, "delete");
1981
- this.store.notifyCollectionChanged(collection, id, "delete");
2322
+ await this.refreshCollectionTargets(collection, "delete", id, false);
1982
2323
  }
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
- }
2324
+ async getCurrentDocumentSnapshot(collection, id) {
2325
+ const localDoc = await this.persistence.getDoc(collection, id);
2326
+ return localDoc && localDoc.exists && !localDoc.deleted ? DocumentSnapshot.fromMap(localDoc.data) : null;
1997
2327
  }
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();
2328
+ async refreshCollectionTargets(collection, source, changedDocId, fromCache = true) {
2329
+ const targets = Array.from(this.collectionTargets.values()).filter(
2330
+ (target) => target.collection === collection
2331
+ );
2332
+ for (const target of targets) {
2333
+ await this.refreshCollectionTarget(target, source, changedDocId, fromCache);
2334
+ }
2335
+ }
2336
+ async refreshCollectionTarget(target, source, changedDocId, fromCache = true) {
2337
+ const snapshots = await target.read();
2338
+ const childChanges = diffSnapshots(target.lastSnapshots, snapshots);
2339
+ target.lastSnapshots = snapshots;
2340
+ this.store.emitCollection(target.targetKey, {
2341
+ snapshots,
2342
+ source,
2343
+ changedDocId,
2344
+ fromCache,
2345
+ childChanges
2346
+ });
2347
+ return snapshots;
2348
+ }
2349
+ releaseCollection(targetKey) {
2350
+ const target = this.collectionTargets.get(targetKey);
2351
+ if (!target)
2352
+ return;
2353
+ target.refCount -= 1;
2354
+ if (target.refCount > 0)
2355
+ return;
2356
+ target.unsub();
2357
+ this.collectionTargets.delete(targetKey);
2358
+ }
2359
+ releaseDocument(key) {
2360
+ const target = this.documentTargets.get(key);
2361
+ if (!target)
2362
+ return;
2363
+ target.refCount -= 1;
2364
+ if (target.refCount > 0)
2365
+ return;
2366
+ target.unsub();
2367
+ this.documentTargets.delete(key);
2006
2368
  }
2007
2369
  };
2008
2370
 
@@ -2014,16 +2376,19 @@ var SyncEngine = class {
2014
2376
  this.retryTimeout = null;
2015
2377
  this.MAX_RETRIES = 5;
2016
2378
  this.BASE_RETRY_DELAY = 2e3;
2379
+ this.handleOnline = () => this.onNetworkOnline();
2380
+ this.handleVisibilityChange = () => {
2381
+ if (document.visibilityState === "visible")
2382
+ this.onNetworkOnline();
2383
+ };
2017
2384
  this.app = app;
2018
2385
  this.persistence = persistence;
2019
2386
  this.store = store;
2020
2387
  this.realtimeBridge = realtimeBridge || null;
2388
+ this.persistence.recoverSyncingMutations().catch(console.error);
2021
2389
  if (typeof window !== "undefined") {
2022
- window.addEventListener("online", () => this.onNetworkOnline());
2023
- document.addEventListener("visibilitychange", () => {
2024
- if (document.visibilityState === "visible")
2025
- this.onNetworkOnline();
2026
- });
2390
+ window.addEventListener("online", this.handleOnline);
2391
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
2027
2392
  }
2028
2393
  }
2029
2394
  onNetworkOnline() {
@@ -2040,6 +2405,7 @@ var SyncEngine = class {
2040
2405
  return;
2041
2406
  this.syncing = true;
2042
2407
  try {
2408
+ await this.persistence.recoverSyncingMutations();
2043
2409
  const pending = await this.persistence.getPendingMutations();
2044
2410
  for (const mutation of pending) {
2045
2411
  if (mutation.retryCount >= this.MAX_RETRIES) {
@@ -2116,8 +2482,7 @@ var SyncEngine = class {
2116
2482
  if (replaced) {
2117
2483
  const snap = DocumentSnapshot.fromMap(replaced.data);
2118
2484
  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");
2485
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, newId, "insert");
2121
2486
  }
2122
2487
  return true;
2123
2488
  }
@@ -2150,7 +2515,7 @@ var SyncEngine = class {
2150
2515
  });
2151
2516
  const snap = DocumentSnapshot.fromMap(local.data);
2152
2517
  this.store.emitDocument(mutation.collection, mutation.documentId, snap, "update");
2153
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "update");
2518
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "update");
2154
2519
  return true;
2155
2520
  }
2156
2521
  async syncDelete(mutation) {
@@ -2167,7 +2532,7 @@ var SyncEngine = class {
2167
2532
  return false;
2168
2533
  await this.persistence.markDeleted(mutation.collection, mutation.documentId, 0);
2169
2534
  this.store.emitDocument(mutation.collection, mutation.documentId, null, "delete");
2170
- this.store.notifyCollectionChanged(mutation.collection, mutation.documentId, "delete");
2535
+ await this.realtimeBridge?.publishLocalChange(mutation.collection, mutation.documentId, "delete");
2171
2536
  return true;
2172
2537
  }
2173
2538
  scheduleRetry() {
@@ -2187,8 +2552,7 @@ var SyncEngine = class {
2187
2552
  * Returns mutations that exceeded MAX_RETRIES
2188
2553
  */
2189
2554
  async getFailedMutations() {
2190
- const all = await this.persistence.getPendingMutations();
2191
- return all.filter((m) => m.status === "failed");
2555
+ return this.persistence.getFailedMutations();
2192
2556
  }
2193
2557
  /**
2194
2558
  * Retry a specific failed mutation by resetting its retry count
@@ -2230,6 +2594,10 @@ var SyncEngine = class {
2230
2594
  if (this.retryTimeout) {
2231
2595
  clearTimeout(this.retryTimeout);
2232
2596
  }
2597
+ if (typeof window !== "undefined") {
2598
+ window.removeEventListener("online", this.handleOnline);
2599
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
2600
+ }
2233
2601
  }
2234
2602
  };
2235
2603