@sharib/geopulse-react-native 0.3.4 → 0.4.0

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.js CHANGED
@@ -60,7 +60,7 @@ var AsyncStorageKeyValueStore = class {
60
60
 
61
61
  // src/config/types.ts
62
62
  var DEFAULT_BACKEND_URL = "https://geofends.onrender.com";
63
- var SDK_VERSION = "0.3.4";
63
+ var SDK_VERSION = "0.4.0";
64
64
 
65
65
  // src/config/key-value-store.ts
66
66
  var InMemoryKeyValueStore = class {
@@ -281,6 +281,24 @@ function resolveExperienceStyle(type, custom) {
281
281
  }
282
282
 
283
283
  // src/firebase/api-client.ts
284
+ var MAX_RETRY_AFTER_MS = 15 * 6e4;
285
+ var ApiError = class extends Error {
286
+ constructor(message, status, retryAfterMs = null) {
287
+ super(message);
288
+ this.status = status;
289
+ this.retryAfterMs = retryAfterMs;
290
+ this.name = "ApiError";
291
+ }
292
+ status;
293
+ retryAfterMs;
294
+ };
295
+ function retryAfterMsFrom(response) {
296
+ const seconds = Number(response.headers?.get?.("retry-after"));
297
+ return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS) : null;
298
+ }
299
+ function failure(callName, response) {
300
+ return new ApiError(`${callName} failed with status ${response.status}`, response.status, response.status === 429 ? retryAfterMsFrom(response) : null);
301
+ }
284
302
  var REQUEST_TIMEOUT_MS = 15e3;
285
303
  async function fetchWithTimeout(url, init) {
286
304
  const controller = new AbortController();
@@ -331,7 +349,7 @@ var ApiClient = class {
331
349
  headers: this.headers()
332
350
  });
333
351
  if (!response.ok) {
334
- throw new Error(`getProjectConfig failed with status ${response.status}`);
352
+ throw failure("getProjectConfig", response);
335
353
  }
336
354
  return await response.json();
337
355
  }
@@ -342,7 +360,7 @@ var ApiClient = class {
342
360
  body: JSON.stringify(payload)
343
361
  });
344
362
  if (!response.ok) {
345
- throw new Error(`ingestLocationEvent failed with status ${response.status}`);
363
+ throw failure("ingestLocationEvent", response);
346
364
  }
347
365
  return await response.json();
348
366
  }
@@ -354,10 +372,10 @@ var ApiClient = class {
354
372
  headers: this.headers()
355
373
  });
356
374
  if (!response.ok) {
357
- throw new Error(`getPendingExperiences failed with status ${response.status}`);
375
+ throw failure("getPendingExperiences", response);
358
376
  }
359
377
  const body = await response.json();
360
- return body.experiences;
378
+ return { experiences: body.experiences ?? [], revoked: body.revoked ?? [] };
361
379
  }
362
380
  /** Confirms a delivery was surfaced via `experience.received`, so it stops appearing in future polls. */
363
381
  async acknowledgeExperienceDelivery(deliveryId) {
@@ -367,7 +385,7 @@ var ApiClient = class {
367
385
  body: JSON.stringify({ deliveryId })
368
386
  });
369
387
  if (!response.ok) {
370
- throw new Error(`acknowledgeExperienceDelivery failed with status ${response.status}`);
388
+ throw failure("acknowledgeExperienceDelivery", response);
371
389
  }
372
390
  }
373
391
  };
@@ -396,27 +414,52 @@ function createEventPayload(params) {
396
414
  }
397
415
 
398
416
  // src/geofencing/state-machine.ts
417
+ var DEFAULT_EXIT_BUFFER_METERS = 20;
418
+ var DEFAULT_MAX_ACCURACY_METERS = 100;
419
+ var DEFAULT_TRANSITION_COOLDOWN_MS = 2 * 6e4;
399
420
  var GeofenceStateMachine = class {
400
421
  membershipByGeofenceId = /* @__PURE__ */ new Map();
422
+ lastTransitionAtByGeofenceId = /* @__PURE__ */ new Map();
423
+ exitBufferMeters;
424
+ maxAccuracyMeters;
425
+ cooldownMs;
426
+ now;
427
+ constructor(options = {}) {
428
+ this.exitBufferMeters = options.exitBufferMeters ?? DEFAULT_EXIT_BUFFER_METERS;
429
+ this.maxAccuracyMeters = options.maxAccuracyMeters ?? DEFAULT_MAX_ACCURACY_METERS;
430
+ this.cooldownMs = options.cooldownMs ?? DEFAULT_TRANSITION_COOLDOWN_MS;
431
+ this.now = options.now ?? Date.now;
432
+ }
401
433
  evaluate(location, geofences) {
434
+ if (location.accuracy !== void 0 && location.accuracy > this.maxAccuracyMeters) {
435
+ return [];
436
+ }
402
437
  const transitions = [];
403
438
  const activeIds = /* @__PURE__ */ new Set();
439
+ const exitBuffer = Math.max(this.exitBufferMeters, location.accuracy ?? 0);
440
+ const now = this.now();
404
441
  for (const geofence of geofences) {
405
442
  activeIds.add(geofence.id);
406
443
  const distance = calculateDistanceMeters(location, geofence.center);
407
- const nextMembership = distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
408
444
  const previousMembership = this.membershipByGeofenceId.get(geofence.id) ?? "OUTSIDE";
409
- if (previousMembership !== nextMembership) {
410
- transitions.push({
411
- geofence,
412
- type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit"
413
- });
445
+ const nextMembership = previousMembership === "INSIDE" ? distance > geofence.radiusMeters + exitBuffer ? "OUTSIDE" : "INSIDE" : distance <= geofence.radiusMeters ? "INSIDE" : "OUTSIDE";
446
+ if (previousMembership === nextMembership) {
447
+ this.membershipByGeofenceId.set(geofence.id, nextMembership);
448
+ continue;
449
+ }
450
+ const lastTransitionAt = this.lastTransitionAtByGeofenceId.get(geofence.id);
451
+ if (lastTransitionAt !== void 0 && now - lastTransitionAt < this.cooldownMs) {
452
+ this.membershipByGeofenceId.set(geofence.id, previousMembership);
453
+ continue;
414
454
  }
415
455
  this.membershipByGeofenceId.set(geofence.id, nextMembership);
456
+ this.lastTransitionAtByGeofenceId.set(geofence.id, now);
457
+ transitions.push({ geofence, type: nextMembership === "INSIDE" ? "geofence.enter" : "geofence.exit" });
416
458
  }
417
- for (const id of this.membershipByGeofenceId.keys()) {
459
+ for (const id of [...this.membershipByGeofenceId.keys()]) {
418
460
  if (!activeIds.has(id)) {
419
461
  this.membershipByGeofenceId.delete(id);
462
+ this.lastTransitionAtByGeofenceId.delete(id);
420
463
  }
421
464
  }
422
465
  return transitions;
@@ -424,8 +467,62 @@ var GeofenceStateMachine = class {
424
467
  getMembership(geofenceId) {
425
468
  return this.membershipByGeofenceId.get(geofenceId) ?? "OUTSIDE";
426
469
  }
470
+ isInsideAny() {
471
+ for (const membership of this.membershipByGeofenceId.values()) {
472
+ if (membership === "INSIDE") return true;
473
+ }
474
+ return false;
475
+ }
476
+ exportState() {
477
+ return {
478
+ membership: Object.fromEntries(this.membershipByGeofenceId),
479
+ lastTransitionAt: Object.fromEntries(this.lastTransitionAtByGeofenceId)
480
+ };
481
+ }
482
+ /** Only for a machine that has not evaluated anything yet. */
483
+ restoreState(state) {
484
+ this.membershipByGeofenceId.clear();
485
+ this.lastTransitionAtByGeofenceId.clear();
486
+ for (const [id, membership] of Object.entries(state.membership)) {
487
+ if (membership === "INSIDE" || membership === "OUTSIDE") this.membershipByGeofenceId.set(id, membership);
488
+ }
489
+ for (const [id, at] of Object.entries(state.lastTransitionAt)) {
490
+ if (typeof at === "number" && Number.isFinite(at)) this.lastTransitionAtByGeofenceId.set(id, at);
491
+ }
492
+ }
427
493
  reset() {
428
494
  this.membershipByGeofenceId.clear();
495
+ this.lastTransitionAtByGeofenceId.clear();
496
+ }
497
+ };
498
+
499
+ // src/geofencing/state-store.ts
500
+ var STORAGE_KEY = "@geopulse/geofence-state";
501
+ var GEOFENCE_STATE_MAX_AGE_MS = 12 * 60 * 60 * 1e3;
502
+ var GeofenceStateStore = class {
503
+ constructor(store, now = Date.now) {
504
+ this.store = store;
505
+ this.now = now;
506
+ }
507
+ store;
508
+ now;
509
+ async load() {
510
+ try {
511
+ const raw = await this.store.getItem(STORAGE_KEY);
512
+ if (!raw) return null;
513
+ const saved = JSON.parse(raw);
514
+ if (typeof saved.savedAt !== "number" || this.now() - saved.savedAt > GEOFENCE_STATE_MAX_AGE_MS) return null;
515
+ if (typeof saved.membership !== "object" || saved.membership === null) return null;
516
+ return { membership: saved.membership, lastTransitionAt: saved.lastTransitionAt ?? {} };
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+ async save(state) {
522
+ try {
523
+ await this.store.setItem(STORAGE_KEY, JSON.stringify({ ...state, savedAt: this.now() }));
524
+ } catch {
525
+ }
429
526
  }
430
527
  };
431
528
 
@@ -492,65 +589,98 @@ var InMemoryEventQueueStore = class {
492
589
  };
493
590
 
494
591
  // src/storage/queue.ts
592
+ var MAX_QUEUE_SIZE = 100;
593
+ var MAX_QUEUED_EVENT_AGE_MS = 24 * 60 * 60 * 1e3;
495
594
  var PendingEventQueue = class {
496
595
  constructor(store = new InMemoryEventQueueStore()) {
497
596
  this.store = store;
498
597
  }
499
598
  store;
500
- async enqueue(payload) {
501
- await this.store.save({
502
- eventId: payload.id,
503
- type: payload.type,
504
- applicationId: payload.applicationId,
505
- deviceId: payload.deviceId,
506
- locationId: payload.locationId,
507
- geofenceId: payload.geofenceId,
508
- timestamp: payload.timestamp,
509
- payload,
510
- attempts: 0,
511
- status: "pending",
512
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
513
- lastAttemptAt: null,
514
- nextAttemptAt: null
599
+ chain = Promise.resolve();
600
+ serialized(operation) {
601
+ const result = this.chain.then(operation, operation);
602
+ this.chain = result.catch(() => void 0);
603
+ return result;
604
+ }
605
+ enqueue(payload) {
606
+ return this.serialized(async () => {
607
+ await this.store.save({
608
+ eventId: payload.id,
609
+ type: payload.type,
610
+ applicationId: payload.applicationId,
611
+ deviceId: payload.deviceId,
612
+ locationId: payload.locationId,
613
+ geofenceId: payload.geofenceId,
614
+ timestamp: payload.timestamp,
615
+ payload,
616
+ attempts: 0,
617
+ status: "pending",
618
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
619
+ lastAttemptAt: null,
620
+ nextAttemptAt: null
621
+ });
622
+ await this.trim();
515
623
  });
516
624
  }
517
625
  /** Every queued event, regardless of retry timing — used for diagnostics/pending counts. */
518
- async listPending() {
519
- return this.store.getAll();
520
- }
521
- /** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). */
522
- async listReadyForRetry(now = /* @__PURE__ */ new Date()) {
523
- const all = await this.store.getAll();
524
- return all.filter(
525
- (event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now)
526
- );
626
+ listPending() {
627
+ return this.serialized(() => this.store.getAll());
628
+ }
629
+ /** Only events whose backoff window has elapsed (or that have never been attempted) — used by SyncManager.retryPending(). Drops events too old to ever be accepted. */
630
+ listReadyForRetry(now = /* @__PURE__ */ new Date()) {
631
+ return this.serialized(async () => {
632
+ let all = await this.store.getAll();
633
+ for (const event of all.filter((candidate) => isTooOld(candidate, now.getTime()))) {
634
+ await this.store.remove(event.eventId);
635
+ }
636
+ all = all.filter((event) => !isTooOld(event, now.getTime()));
637
+ return all.filter((event) => event.status !== "sending" && (!event.nextAttemptAt || new Date(event.nextAttemptAt) <= now));
638
+ });
527
639
  }
528
640
  /** Marks an event as in-flight so a concurrent retry pass won't also pick it up. */
529
- async markSending(eventId) {
530
- const existing = await this.get(eventId);
531
- if (!existing) return;
532
- await this.store.save({ ...existing, status: "sending" });
533
- }
534
- async markAttemptFailed(eventId) {
535
- const existing = await this.get(eventId);
536
- if (!existing) return;
537
- const attempts = existing.attempts + 1;
538
- const now = /* @__PURE__ */ new Date();
539
- await this.store.save({
540
- ...existing,
541
- status: "failed",
542
- attempts,
543
- lastAttemptAt: now.toISOString(),
544
- nextAttemptAt: new Date(now.getTime() + computeRetryDelayMs(attempts)).toISOString()
641
+ markSending(eventId) {
642
+ return this.serialized(async () => {
643
+ const existing = await this.get(eventId);
644
+ if (!existing) return;
645
+ await this.store.save({ ...existing, status: "sending" });
545
646
  });
546
647
  }
547
- async markSent(eventId) {
548
- await this.store.remove(eventId);
648
+ /** `minDelayMs` lets a 429's Retry-After push the next attempt later than the normal backoff. */
649
+ markAttemptFailed(eventId, minDelayMs = 0) {
650
+ return this.serialized(async () => {
651
+ const existing = await this.get(eventId);
652
+ if (!existing) return;
653
+ const attempts = existing.attempts + 1;
654
+ const now = /* @__PURE__ */ new Date();
655
+ await this.store.save({
656
+ ...existing,
657
+ status: "failed",
658
+ attempts,
659
+ lastAttemptAt: now.toISOString(),
660
+ nextAttemptAt: new Date(now.getTime() + Math.max(computeRetryDelayMs(attempts), minDelayMs)).toISOString()
661
+ });
662
+ });
663
+ }
664
+ markSent(eventId) {
665
+ return this.serialized(() => this.store.remove(eventId));
549
666
  }
550
667
  async get(eventId) {
551
668
  return (await this.store.getAll()).find((event) => event.eventId === eventId);
552
669
  }
670
+ /** Keeps the queue within MAX_QUEUE_SIZE by dropping the oldest events that are not being sent right now. */
671
+ async trim() {
672
+ const all = await this.store.getAll();
673
+ if (all.length <= MAX_QUEUE_SIZE) return;
674
+ const droppable = all.filter((event) => event.status !== "sending").sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
675
+ for (const event of droppable.slice(0, all.length - MAX_QUEUE_SIZE)) {
676
+ await this.store.remove(event.eventId);
677
+ }
678
+ }
553
679
  };
680
+ function isTooOld(event, nowMs) {
681
+ const eventTime = Date.parse(event.timestamp);
682
+ return !Number.isNaN(eventTime) && nowMs - eventTime > MAX_QUEUED_EVENT_AGE_MS;
683
+ }
554
684
 
555
685
  // src/sync/network-monitor.ts
556
686
  var ManualNetworkMonitor = class {
@@ -572,20 +702,29 @@ var ManualNetworkMonitor = class {
572
702
 
573
703
  // src/sync/sync-manager.ts
574
704
  var DEFAULT_RETRY_INTERVAL_MS = 3e4;
705
+ var MAX_UPLOADS_PER_MINUTE = 3;
706
+ var FORCED_RETRY_MIN_GAP_MS = 1e4;
707
+ var FORBIDDEN_PAUSE_MS = MAX_RETRY_AFTER_MS;
708
+ var DEFAULT_RATE_LIMIT_PAUSE_MS = 6e4;
575
709
  var SyncManager = class {
576
- constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger) {
710
+ constructor(apiClient, queue, retryIntervalMs = DEFAULT_RETRY_INTERVAL_MS, logger, onDelivered) {
577
711
  this.apiClient = apiClient;
578
712
  this.queue = queue;
579
713
  this.retryIntervalMs = retryIntervalMs;
580
714
  this.logger = logger;
715
+ this.onDelivered = onDelivered;
581
716
  }
582
717
  apiClient;
583
718
  queue;
584
719
  retryIntervalMs;
585
720
  logger;
721
+ onDelivered;
586
722
  retryTimer = null;
587
723
  lastSyncAt = null;
588
724
  lastUploadResult = null;
725
+ uploadTimes = [];
726
+ pausedUntil = 0;
727
+ lastForcedRetryAt = 0;
589
728
  /**
590
729
  * Write-ahead: the event is durably queued *before* any network attempt is
591
730
  * made, so an app kill mid-request can never lose it (spec: "true
@@ -597,8 +736,18 @@ var SyncManager = class {
597
736
  */
598
737
  async sendOrQueue(payload) {
599
738
  await this.queue.enqueue(payload);
739
+ if (!this.canUploadNow()) {
740
+ this.logger?.log("Upload deferred (rate cap or server asked to wait)", { eventId: payload.id });
741
+ return;
742
+ }
600
743
  await this.attemptDelivery(payload.id, payload);
601
744
  }
745
+ canUploadNow() {
746
+ const now = Date.now();
747
+ if (now < this.pausedUntil) return false;
748
+ this.uploadTimes = this.uploadTimes.filter((at) => now - at < 6e4);
749
+ return this.uploadTimes.length < MAX_UPLOADS_PER_MINUTE;
750
+ }
602
751
  /**
603
752
  * Attempts queued events. By default only ones whose backoff window has
604
753
  * elapsed (the timer-driven path, `startAutoRetry` — the whole point of
@@ -611,9 +760,17 @@ var SyncManager = class {
611
760
  * once it's actually back online.
612
761
  */
613
762
  async retryPending(options = {}) {
614
- const ready = options.force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
615
- const result = { attempted: ready.length, delivered: 0, failed: 0 };
763
+ let force = options.force === true;
764
+ if (force) {
765
+ const now = Date.now();
766
+ if (now - this.lastForcedRetryAt < FORCED_RETRY_MIN_GAP_MS) force = false;
767
+ else this.lastForcedRetryAt = now;
768
+ }
769
+ const ready = force ? (await this.queue.listPending()).filter((event) => event.status !== "sending") : await this.queue.listReadyForRetry();
770
+ const result = { attempted: 0, delivered: 0, failed: 0 };
616
771
  for (const event of ready) {
772
+ if (!this.canUploadNow()) break;
773
+ result.attempted++;
617
774
  const attemptNumber = event.attempts + 1;
618
775
  const delivered = await this.attemptDelivery(event.eventId, { ...event.payload, attempts: attemptNumber });
619
776
  if (delivered) {
@@ -634,21 +791,39 @@ var SyncManager = class {
634
791
  * was delivered, purely so `retryPending` can tally its `SyncResult`.
635
792
  */
636
793
  async attemptDelivery(eventId, payload) {
794
+ this.uploadTimes.push(Date.now());
637
795
  await this.queue.markSending(eventId);
638
796
  this.logger?.log("Event upload started", { eventId, attempt: payload.attempts });
797
+ let result;
639
798
  try {
640
- await this.apiClient.ingestEvent(payload);
799
+ result = await this.apiClient.ingestEvent(payload);
641
800
  await this.queue.markSent(eventId);
642
801
  this.lastSyncAt = (/* @__PURE__ */ new Date()).toISOString();
643
802
  this.lastUploadResult = "success";
644
803
  this.logger?.log("Event upload succeeded", { eventId });
645
- return true;
646
804
  } catch (error) {
647
- await this.queue.markAttemptFailed(eventId);
648
805
  this.lastUploadResult = "failure";
649
- this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
806
+ if (error instanceof ApiError && error.status === 429) {
807
+ const waitMs = error.retryAfterMs ?? DEFAULT_RATE_LIMIT_PAUSE_MS;
808
+ this.pausedUntil = Date.now() + waitMs;
809
+ await this.queue.markAttemptFailed(eventId, waitMs);
810
+ this.logger?.warn("Server rate limit \u2014 pausing uploads", { eventId, waitMs });
811
+ } else if (error instanceof ApiError && isPermanentRejection(error.status)) {
812
+ await this.queue.markSent(eventId);
813
+ if (error.status === 403) this.pausedUntil = Date.now() + FORBIDDEN_PAUSE_MS;
814
+ this.logger?.warn("Event rejected permanently \u2014 dropped", { eventId, status: error.status });
815
+ } else {
816
+ await this.queue.markAttemptFailed(eventId);
817
+ this.logger?.warn("Event upload failed \u2014 retry scheduled", { eventId, error: String(error) });
818
+ }
650
819
  return false;
651
820
  }
821
+ try {
822
+ this.onDelivered?.(result);
823
+ } catch (error) {
824
+ this.logger?.warn("onDelivered handler threw", { eventId, error: String(error) });
825
+ }
826
+ return true;
652
827
  }
653
828
  startAutoRetry() {
654
829
  if (this.retryTimer) return;
@@ -669,6 +844,9 @@ var SyncManager = class {
669
844
  return this.lastUploadResult;
670
845
  }
671
846
  };
847
+ function isPermanentRejection(status) {
848
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
849
+ }
672
850
 
673
851
  // src/core/logger.ts
674
852
  var Logger = class {
@@ -704,7 +882,8 @@ var ApplicationMismatchError = class extends Error {
704
882
  this.name = "ApplicationMismatchError";
705
883
  }
706
884
  };
707
- var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 3e4;
885
+ var DEFAULT_EXPERIENCE_POLL_INTERVAL_MS = 15 * 6e4;
886
+ var CONFIG_REFRESH_INTERVAL_MS = 60 * 6e4;
708
887
  var MIN_TIMER_INTERVAL_MS = 1e3;
709
888
  function clampIntervalMs(value) {
710
889
  if (value === void 0) {
@@ -731,12 +910,25 @@ var GeoPulseSdk = class {
731
910
  deviceId = null;
732
911
  locationError = null;
733
912
  unsubscribeNetwork = null;
734
- stateMachine = new GeofenceStateMachine();
913
+ stateMachine;
914
+ geofenceStateStore;
915
+ geofenceStateRestored = false;
916
+ realtime = null;
917
+ createRealtime;
918
+ lastConfigFetchAt = 0;
919
+ configRefreshInFlight = false;
735
920
  listeners = /* @__PURE__ */ new Map();
736
921
  experienceListeners = /* @__PURE__ */ new Set();
737
922
  /** Client-side duplicate protection (spec section 14) — belt-and-suspenders alongside the server-side ack; a delivery already acked but re-observed before the ack lands must not fire `experience.received` twice. */
738
- seenExperienceDeliveryIds = /* @__PURE__ */ new Set();
923
+ seenExperienceRevisions = /* @__PURE__ */ new Map();
924
+ experienceRemovedListeners = /* @__PURE__ */ new Set();
925
+ removedDeliveryIds = /* @__PURE__ */ new Set();
739
926
  experiencePollTimer = null;
927
+ experiencePollInFlight = false;
928
+ /** Set while a poll that was refused with "too many requests" waits to be tried again. */
929
+ pollRetryTimer = null;
930
+ /** Set when a poll was asked for while one was already running; that one may have queried before the new delivery existed. */
931
+ experiencePollRequested = false;
740
932
  experiencesReceivedCount = 0;
741
933
  logger;
742
934
  locationProvider;
@@ -758,6 +950,9 @@ var GeoPulseSdk = class {
758
950
  this.networkMonitor = dependencies.networkMonitor ?? new ManualNetworkMonitor();
759
951
  this.getPlatform = dependencies.getPlatform ?? (() => "unknown");
760
952
  this.logger = new Logger(dependencies.debug ?? false);
953
+ this.stateMachine = new GeofenceStateMachine(dependencies.stateMachineOptions);
954
+ this.geofenceStateStore = new GeofenceStateStore(dependencies.stateStore ?? new InMemoryKeyValueStore());
955
+ this.createRealtime = dependencies.createRealtimeClient;
761
956
  }
762
957
  /**
763
958
  * Resolves this device's persistent id, then fetches (or falls back to
@@ -783,8 +978,25 @@ var GeoPulseSdk = class {
783
978
  this.config = config;
784
979
  this.experiencePollIntervalMs = clampIntervalMs(config.experiencePollIntervalMs) ?? DEFAULT_EXPERIENCE_POLL_INTERVAL_MS;
785
980
  this.apiClient = this.buildApiClient(config);
786
- this.syncManager = new SyncManager(this.apiClient, this.queue, clampIntervalMs(config.retryIntervalMs), this.logger);
981
+ this.syncManager = new SyncManager(
982
+ this.apiClient,
983
+ this.queue,
984
+ clampIntervalMs(config.retryIntervalMs),
985
+ this.logger,
986
+ (result) => this.handleEventDelivered(result)
987
+ );
787
988
  this.deviceId = await getOrCreateDeviceId(this.deviceIdStore);
989
+ this.realtime?.disconnect();
990
+ this.realtime = this.createRealtime ? this.createRealtime({
991
+ baseUrl: config.functionsBaseUrl || DEFAULT_BACKEND_URL,
992
+ sdkKey: config.apiKey,
993
+ applicationId: config.applicationId,
994
+ deviceId: this.deviceId,
995
+ onChanged: () => void this.pollForExperiences(),
996
+ // Whatever changed while the socket was down is picked up now.
997
+ onConnected: () => void this.pollForExperiences(),
998
+ logger: this.logger
999
+ }) : null;
788
1000
  try {
789
1001
  const projectConfig = await this.apiClient.getProjectConfig({
790
1002
  deviceId: this.deviceId,
@@ -796,6 +1008,7 @@ var GeoPulseSdk = class {
796
1008
  }
797
1009
  this.applyProjectConfig(projectConfig);
798
1010
  this.configurationStatus = "synced";
1011
+ this.lastConfigFetchAt = Date.now();
799
1012
  await this.configCache.save(projectConfig);
800
1013
  this.status = "initialized";
801
1014
  this.logger.log("Configuration synced", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
@@ -834,6 +1047,11 @@ var GeoPulseSdk = class {
834
1047
  this.status = "error";
835
1048
  throw new Error(`Location permission not granted (status: ${permission})`);
836
1049
  }
1050
+ if (!this.geofenceStateRestored) {
1051
+ this.geofenceStateRestored = true;
1052
+ const saved = await this.geofenceStateStore.load();
1053
+ if (saved) this.stateMachine.restoreState(saved);
1054
+ }
837
1055
  this.locationError = null;
838
1056
  await this.locationProvider.start(
839
1057
  (location) => {
@@ -857,12 +1075,14 @@ var GeoPulseSdk = class {
857
1075
  this.syncManager?.startAutoRetry();
858
1076
  this.startExperiencePolling();
859
1077
  this.status = "running";
1078
+ this.syncRealtime();
860
1079
  this.logger.log("GeoPulse.start()");
861
1080
  }
862
1081
  stop() {
863
1082
  this.locationProvider.stop();
864
1083
  this.syncManager?.stopAutoRetry();
865
1084
  this.stopExperiencePolling();
1085
+ this.realtime?.disconnect();
866
1086
  this.unsubscribeNetwork?.();
867
1087
  this.unsubscribeNetwork = null;
868
1088
  this.status = "stopped";
@@ -879,6 +1099,10 @@ var GeoPulseSdk = class {
879
1099
  this.experienceListeners.add(callback);
880
1100
  return;
881
1101
  }
1102
+ if (eventName === "experience.removed") {
1103
+ this.experienceRemovedListeners.add(callback);
1104
+ return;
1105
+ }
882
1106
  if (!this.listeners.has(eventName)) {
883
1107
  this.listeners.set(eventName, /* @__PURE__ */ new Set());
884
1108
  }
@@ -889,6 +1113,10 @@ var GeoPulseSdk = class {
889
1113
  this.experienceListeners.delete(callback);
890
1114
  return;
891
1115
  }
1116
+ if (eventName === "experience.removed") {
1117
+ this.experienceRemovedListeners.delete(callback);
1118
+ return;
1119
+ }
892
1120
  this.listeners.get(eventName)?.delete(callback);
893
1121
  }
894
1122
  // --- Small read-only accessors beyond the core 7-method API, so a host
@@ -970,28 +1198,64 @@ var GeoPulseSdk = class {
970
1198
  void this.pollForExperiences();
971
1199
  this.experiencePollTimer = setInterval(() => {
972
1200
  void this.pollForExperiences();
1201
+ void this.refreshConfigIfStale();
973
1202
  }, this.experiencePollIntervalMs);
974
1203
  }
975
1204
  stopExperiencePolling() {
1205
+ if (this.pollRetryTimer) {
1206
+ clearTimeout(this.pollRetryTimer);
1207
+ this.pollRetryTimer = null;
1208
+ }
976
1209
  if (this.experiencePollTimer) {
977
1210
  clearInterval(this.experiencePollTimer);
978
1211
  this.experiencePollTimer = null;
979
1212
  }
980
1213
  }
1214
+ /**
1215
+ * The backend says in its ingest response whether the event created an experience for this
1216
+ * device. Poll right away if so (or if it doesn't say — an older backend), skip if it says
1217
+ * there is nothing. Does nothing unless polling is active, so a stopped SDK never polls.
1218
+ */
1219
+ handleEventDelivered(result) {
1220
+ if (this.experiencePollTimer === null || result?.experiencesPending === false) {
1221
+ return;
1222
+ }
1223
+ void this.pollForExperiences();
1224
+ }
1225
+ /** One poll at a time: a second request while one is running makes it run once more afterwards, rather than overlapping. */
981
1226
  async pollForExperiences() {
1227
+ if (this.experiencePollInFlight) {
1228
+ this.experiencePollRequested = true;
1229
+ return;
1230
+ }
1231
+ this.experiencePollInFlight = true;
1232
+ try {
1233
+ do {
1234
+ this.experiencePollRequested = false;
1235
+ await this.pollForExperiencesOnce();
1236
+ } while (this.experiencePollRequested);
1237
+ } finally {
1238
+ this.experiencePollInFlight = false;
1239
+ }
1240
+ }
1241
+ async pollForExperiencesOnce() {
982
1242
  if (!this.apiClient || !this.deviceId) {
983
1243
  return;
984
1244
  }
985
1245
  try {
986
- const experiences = await this.apiClient.getPendingExperiences(this.deviceId);
1246
+ const { experiences, revoked } = await this.apiClient.getPendingExperiences(this.deviceId);
987
1247
  for (const experience of experiences) {
988
- if (this.seenExperienceDeliveryIds.has(experience.deliveryId)) {
1248
+ const revision = experience.revision ?? 0;
1249
+ const seenRevision = this.seenExperienceRevisions.get(experience.deliveryId);
1250
+ if (seenRevision === revision) {
989
1251
  continue;
990
1252
  }
991
- this.seenExperienceDeliveryIds.add(experience.deliveryId);
1253
+ const isUpdate = seenRevision !== void 0;
1254
+ this.seenExperienceRevisions.set(experience.deliveryId, revision);
1255
+ this.removedDeliveryIds.delete(experience.deliveryId);
992
1256
  this.experiencesReceivedCount += 1;
993
- this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId });
994
- this.experienceListeners.forEach((listener) => listener(experience));
1257
+ this.logger.log("Experience received", { deliveryId: experience.deliveryId, campaignId: experience.campaignId, isUpdate });
1258
+ this.experienceListeners.forEach((listener) => listener({ ...experience, isUpdate }));
995
1259
  await this.apiClient.acknowledgeExperienceDelivery(experience.deliveryId).catch((error) => {
996
1260
  this.logger.warn("Failed to acknowledge experience delivery", {
997
1261
  deliveryId: experience.deliveryId,
@@ -999,10 +1263,84 @@ var GeoPulseSdk = class {
999
1263
  });
1000
1264
  });
1001
1265
  }
1266
+ for (const removed of revoked) {
1267
+ if (!this.removedDeliveryIds.has(removed.deliveryId)) {
1268
+ this.removedDeliveryIds.add(removed.deliveryId);
1269
+ this.seenExperienceRevisions.delete(removed.deliveryId);
1270
+ this.logger.log("Experience removed", { deliveryId: removed.deliveryId, campaignId: removed.campaignId });
1271
+ this.experienceRemovedListeners.forEach((listener) => listener(removed));
1272
+ }
1273
+ await this.apiClient.acknowledgeExperienceDelivery(removed.deliveryId).catch((error) => {
1274
+ this.logger.warn("Failed to acknowledge experience removal", {
1275
+ deliveryId: removed.deliveryId,
1276
+ error: error instanceof Error ? error.message : String(error)
1277
+ });
1278
+ });
1279
+ }
1002
1280
  } catch (error) {
1003
1281
  this.logger.warn("Polling for experiences failed", { error: error instanceof Error ? error.message : String(error) });
1282
+ if (error instanceof ApiError && error.status === 429) {
1283
+ this.schedulePollRetry(error.retryAfterMs ?? 3e4);
1284
+ }
1004
1285
  }
1005
1286
  }
1287
+ schedulePollRetry(waitMs) {
1288
+ if (this.pollRetryTimer !== null || this.experiencePollTimer === null) {
1289
+ return;
1290
+ }
1291
+ this.pollRetryTimer = setTimeout(() => {
1292
+ this.pollRetryTimer = null;
1293
+ void this.pollForExperiences();
1294
+ }, waitMs + Math.random() * 5e3);
1295
+ }
1296
+ /**
1297
+ * Re-reads the geofence list when it is an hour old, so a geofence an admin added, moved, resized or removed reaches a phone
1298
+ * that keeps the app open (otherwise only a restart picks it up). Runs from the poll timer, so it costs no timer of its own.
1299
+ * A failure keeps the list we have. A disabled application stops the SDK.
1300
+ */
1301
+ async refreshConfigIfStale() {
1302
+ if (!this.apiClient || !this.deviceId || !this.config || this.configRefreshInFlight) {
1303
+ return;
1304
+ }
1305
+ if (Date.now() - this.lastConfigFetchAt < CONFIG_REFRESH_INTERVAL_MS) {
1306
+ return;
1307
+ }
1308
+ this.configRefreshInFlight = true;
1309
+ try {
1310
+ const projectConfig = await this.apiClient.getProjectConfig({ deviceId: this.deviceId, platform: this.getPlatform(), sdkVersion: SDK_VERSION });
1311
+ if (projectConfig.applicationId !== this.config.applicationId) {
1312
+ return;
1313
+ }
1314
+ this.lastConfigFetchAt = Date.now();
1315
+ this.applyProjectConfig(projectConfig);
1316
+ this.configurationStatus = "synced";
1317
+ await this.configCache.save(projectConfig);
1318
+ this.logger.log("Configuration refreshed", { geofenceCount: projectConfig.geofences.length, configVersion: projectConfig.configVersion });
1319
+ if (!projectConfig.applicationEnabled) {
1320
+ this.logger.warn("Application disabled \u2014 stopping");
1321
+ this.stop();
1322
+ this.status = "error";
1323
+ }
1324
+ } catch (error) {
1325
+ this.logger.warn("Configuration refresh failed \u2014 keeping the current geofences", { error: error instanceof Error ? error.message : String(error) });
1326
+ } finally {
1327
+ this.configRefreshInFlight = false;
1328
+ }
1329
+ }
1330
+ /** Keeps the push connection open exactly while the user is inside a geofence. */
1331
+ syncRealtime() {
1332
+ if (!this.realtime || this.status === "stopped") {
1333
+ return;
1334
+ }
1335
+ if (this.stateMachine.isInsideAny()) {
1336
+ this.realtime.connect();
1337
+ } else {
1338
+ this.realtime.disconnect();
1339
+ }
1340
+ }
1341
+ async persistGeofenceState() {
1342
+ await this.geofenceStateStore.save(this.stateMachine.exportState());
1343
+ }
1006
1344
  applyProjectConfig(projectConfig) {
1007
1345
  this.resolvedProjectId = projectConfig.projectId;
1008
1346
  this.resolvedApplicationId = projectConfig.applicationId;
@@ -1016,6 +1354,10 @@ var GeoPulseSdk = class {
1016
1354
  return;
1017
1355
  }
1018
1356
  const transitions = this.stateMachine.evaluate(location, this.geofences);
1357
+ if (transitions.length > 0) {
1358
+ void this.persistGeofenceState();
1359
+ }
1360
+ this.syncRealtime();
1019
1361
  for (const transition of transitions) {
1020
1362
  this.logger.log("Geofence state changed", { geofenceId: transition.geofence.id, type: transition.type });
1021
1363
  const event = createEventPayload({
@@ -1054,7 +1396,8 @@ var DeviceLocationProvider = class {
1054
1396
  onUpdate({
1055
1397
  latitude: position.coords.latitude,
1056
1398
  longitude: position.coords.longitude,
1057
- timestamp: new Date(position.timestamp).toISOString()
1399
+ timestamp: new Date(position.timestamp).toISOString(),
1400
+ ...typeof position.coords.accuracy === "number" ? { accuracy: position.coords.accuracy } : {}
1058
1401
  });
1059
1402
  }
1060
1403
  );
@@ -1093,10 +1436,10 @@ var ExpoPermissionProvider = class {
1093
1436
 
1094
1437
  // src/storage/async-storage-store.ts
1095
1438
  var import_async_storage2 = __toESM(require("@react-native-async-storage/async-storage"));
1096
- var STORAGE_KEY = "@geopulse/pending-events";
1439
+ var STORAGE_KEY2 = "@geopulse/pending-events";
1097
1440
  var AsyncStorageEventQueueStore = class {
1098
1441
  async getAll() {
1099
- const raw = await import_async_storage2.default.getItem(STORAGE_KEY);
1442
+ const raw = await import_async_storage2.default.getItem(STORAGE_KEY2);
1100
1443
  if (!raw) return [];
1101
1444
  try {
1102
1445
  return JSON.parse(raw);
@@ -1107,14 +1450,112 @@ var AsyncStorageEventQueueStore = class {
1107
1450
  async save(event) {
1108
1451
  const all = await this.getAll();
1109
1452
  const next = [...all.filter((existing) => existing.eventId !== event.eventId), event];
1110
- await import_async_storage2.default.setItem(STORAGE_KEY, JSON.stringify(next));
1453
+ await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(next));
1111
1454
  }
1112
1455
  async remove(eventId) {
1113
1456
  const all = await this.getAll();
1114
- await import_async_storage2.default.setItem(STORAGE_KEY, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
1457
+ await import_async_storage2.default.setItem(STORAGE_KEY2, JSON.stringify(all.filter((existing) => existing.eventId !== eventId)));
1115
1458
  }
1116
1459
  };
1117
1460
 
1461
+ // src/realtime/realtime-client.ts
1462
+ var RECONNECT_BASE_MS = 5e3;
1463
+ var RECONNECT_MAX_MS = 5 * 6e4;
1464
+ var NO_RETRY_CODES = /* @__PURE__ */ new Set([4400, 4401, 4403]);
1465
+ function toRealtimeUrl(baseUrl) {
1466
+ return `${baseUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/realtime`;
1467
+ }
1468
+ var RealtimeClient = class {
1469
+ constructor(options) {
1470
+ this.options = options;
1471
+ }
1472
+ options;
1473
+ socket = null;
1474
+ connected = false;
1475
+ wanted = false;
1476
+ failures = 0;
1477
+ reconnectTimer = null;
1478
+ isConnected() {
1479
+ return this.connected;
1480
+ }
1481
+ connect() {
1482
+ this.wanted = true;
1483
+ if (this.socket || this.reconnectTimer) return;
1484
+ this.open();
1485
+ }
1486
+ disconnect() {
1487
+ this.wanted = false;
1488
+ this.failures = 0;
1489
+ if (this.reconnectTimer) {
1490
+ clearTimeout(this.reconnectTimer);
1491
+ this.reconnectTimer = null;
1492
+ }
1493
+ const socket = this.socket;
1494
+ this.socket = null;
1495
+ this.connected = false;
1496
+ if (socket) {
1497
+ socket.onclose = null;
1498
+ socket.onmessage = null;
1499
+ socket.onerror = null;
1500
+ try {
1501
+ socket.close(1e3, "done");
1502
+ } catch {
1503
+ }
1504
+ }
1505
+ }
1506
+ open() {
1507
+ const create = this.options.createSocket ?? defaultCreateSocket;
1508
+ let socket;
1509
+ try {
1510
+ socket = create(toRealtimeUrl(this.options.baseUrl));
1511
+ } catch (error) {
1512
+ this.options.logger?.warn("Realtime unavailable", { error: String(error) });
1513
+ return;
1514
+ }
1515
+ if (!socket) return;
1516
+ this.socket = socket;
1517
+ socket.onopen = () => {
1518
+ socket.send(JSON.stringify({ type: "hello", sdkKey: this.options.sdkKey, applicationId: this.options.applicationId, deviceId: this.options.deviceId }));
1519
+ };
1520
+ socket.onmessage = (event) => {
1521
+ let message = null;
1522
+ try {
1523
+ message = JSON.parse(String(event.data));
1524
+ } catch {
1525
+ return;
1526
+ }
1527
+ if (message?.type === "ready") {
1528
+ this.connected = true;
1529
+ this.failures = 0;
1530
+ this.options.onConnected();
1531
+ } else if (message?.type === "changed") {
1532
+ this.options.onChanged();
1533
+ }
1534
+ };
1535
+ socket.onerror = () => {
1536
+ };
1537
+ socket.onclose = (event) => {
1538
+ this.socket = null;
1539
+ this.connected = false;
1540
+ if (!this.wanted) return;
1541
+ if (NO_RETRY_CODES.has(event.code)) {
1542
+ this.options.logger?.warn("Realtime rejected by the server; not retrying", { code: event.code });
1543
+ return;
1544
+ }
1545
+ this.failures += 1;
1546
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.failures - 1), RECONNECT_MAX_MS);
1547
+ this.reconnectTimer = setTimeout(() => {
1548
+ this.reconnectTimer = null;
1549
+ if (this.wanted) this.open();
1550
+ }, Math.round(delay * (0.8 + Math.random() * 0.4)));
1551
+ };
1552
+ }
1553
+ };
1554
+ function defaultCreateSocket(url) {
1555
+ const Ctor = globalThis.WebSocket;
1556
+ return Ctor ? new Ctor(url) : null;
1557
+ }
1558
+
1118
1559
  // src/sync/netinfo-network-monitor.ts
1119
1560
  var import_netinfo = __toESM(require("@react-native-community/netinfo"));
1120
1561
  function toStatus(isConnected) {
@@ -1252,6 +1693,11 @@ var sdk = new GeoPulseSdk({
1252
1693
  queue: new PendingEventQueue(new AsyncStorageEventQueueStore()),
1253
1694
  deviceIdStore: persistentKeyValueStore,
1254
1695
  configCacheStore: persistentKeyValueStore,
1696
+ // The simulator is for trying things out: no 2-minute cooldown, and no saved "inside" state surviving a reload
1697
+ // (otherwise "enter" would not fire again after Fast Refresh while the saved state says you are still inside).
1698
+ stateStore: useSimulatedProviders ? void 0 : persistentKeyValueStore,
1699
+ stateMachineOptions: useSimulatedProviders ? { cooldownMs: 0 } : void 0,
1700
+ createRealtimeClient: (options) => new RealtimeClient(options),
1255
1701
  networkMonitor: new NetInfoNetworkMonitor(),
1256
1702
  getPlatform,
1257
1703
  debug: isDev