@layers/amba-react-native 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -351,10 +351,806 @@ function freshSeed() {
351
351
  };
352
352
  }
353
353
 
354
+ //#endregion
355
+ //#region src/resilience.ts
356
+ /**
357
+ * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
358
+ * exists; under Node / SSR it doesn't, so we treat "unknown" as online
359
+ * (the executor's own error handling then decides).
360
+ */
361
+ function isOnline() {
362
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
363
+ if (nav && typeof nav.onLine === "boolean") return nav.onLine;
364
+ return true;
365
+ }
366
+ /**
367
+ * Error codes the server raised deterministically — replaying them would fail
368
+ * identically, so they're NEVER queued even when the device reports offline.
369
+ * (A request can fail validation/auth/etc. while `navigator.onLine` is briefly
370
+ * false; we must not buffer-and-retry those forever.)
371
+ */
372
+ const DETERMINISTIC_ERROR_CODES = new Set([
373
+ "VALIDATION_ERROR",
374
+ "INVALID_ARGUMENT",
375
+ "INVALID_INPUT",
376
+ "UNAUTHORIZED",
377
+ "FORBIDDEN",
378
+ "NOT_FOUND",
379
+ "CONFLICT",
380
+ "CONSENT_NOT_GRANTED",
381
+ "NOT_INITIALIZED"
382
+ ]);
383
+ /**
384
+ * Is this a deterministic server rejection — a coded error the server would
385
+ * raise identically on every replay (bad input, auth, not-found, conflict)? A
386
+ * queued op that fails this way is poison: retrying it forever would wedge the
387
+ * queue, so the flush DROPS it. Everything else (transport failures, 5xx
388
+ * `HTTP_ERROR`, `RATE_LIMITED`, `CIRCUIT_OPEN`, anything uncoded) is treated as
389
+ * transient and RETAINED for a later attempt — favoring retain over data loss.
390
+ */
391
+ function isDeterministicError(err) {
392
+ const code = err?.code;
393
+ return typeof code === "string" && DETERMINISTIC_ERROR_CODES.has(code);
394
+ }
395
+ /**
396
+ * Heuristic: does a thrown value look like a transient offline / transport
397
+ * failure (vs a deterministic 4xx the server would reject again)? Used at
398
+ * ENQUEUE time to decide whether a failed write is worth buffering — a
399
+ * `VALIDATION_ERROR` will fail identically on replay, so it throws instead.
400
+ *
401
+ * A deterministic server error is excluded even when the device reports
402
+ * offline: the request clearly reached the server (it got a coded response),
403
+ * so retrying it on reconnect is pointless and would wedge the queue.
404
+ *
405
+ * NOTE: this is intentionally stricter than {@link isDeterministicError}'s
406
+ * inverse — at enqueue time we only buffer GENUINE connectivity failures (a
407
+ * non-offline 5xx/429 surfaces to the caller immediately rather than silently
408
+ * queueing). The flush drop-decision uses {@link isDeterministicError} so a
409
+ * transient 5xx/429 encountered DURING replay is retained, not dropped.
410
+ */
411
+ function isOfflineError(err) {
412
+ const code = err?.code;
413
+ if (typeof code === "string" && DETERMINISTIC_ERROR_CODES.has(code)) return false;
414
+ if (code === "NETWORK_ERROR") return true;
415
+ const msg = err?.message;
416
+ if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
417
+ return !isOnline();
418
+ }
419
+ /** Generate a stable idempotency key for a queued op. */
420
+ function newIdempotencyKey() {
421
+ const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
422
+ if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
423
+ return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
424
+ }
425
+ /**
426
+ * Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
427
+ * Single in-flight flush is enforced so concurrent reconnect signals don't
428
+ * double-replay.
429
+ */
430
+ var OfflineQueue = class {
431
+ store;
432
+ executor;
433
+ storageKey;
434
+ /**
435
+ * Notified when a queued op is dropped because its replay failed
436
+ * deterministically (a poisoned op the server will always reject). Lets the
437
+ * SDK surface the loss (debug log) instead of silently discarding.
438
+ */
439
+ onDropped;
440
+ flushing = null;
441
+ /**
442
+ * Serializes every read-modify-write of the persisted outbox. Each mutation
443
+ * chains onto the previous one's promise, so concurrent `enqueue`s — and an
444
+ * `enqueue` racing the per-op removal inside `flush` — can never read a stale
445
+ * snapshot and clobber each other's writes (the lost-update bug). All
446
+ * storage mutations funnel through `mutate()`; only `pendingCount`/`pending`
447
+ * read without taking the lock (a stale-by-one read is harmless).
448
+ */
449
+ tail = Promise.resolve();
450
+ /** Detach function for the auto-flush `online` listener, if installed. */
451
+ detachOnline = null;
452
+ /**
453
+ * Set once `dispose()` is called (on teardown / reconfigure). A disposed
454
+ * queue refuses to mutate the persisted outbox, so an orphaned in-flight
455
+ * flush — or any late call against the old instance — can never race the new
456
+ * queue by reading/writing the same storage after it's been replaced.
457
+ */
458
+ disposed = false;
459
+ constructor(opts) {
460
+ this.store = opts.store;
461
+ this.executor = opts.executor;
462
+ this.storageKey = opts.storageKey ?? "offline_outbox";
463
+ this.onDropped = opts.onDropped;
464
+ }
465
+ /**
466
+ * Run `fn` exclusively against the persisted outbox: it chains after every
467
+ * prior mutation and the next one waits for it. `fn` receives the current
468
+ * ops and returns the next ops to persist plus its own result.
469
+ */
470
+ mutate(fn, opts) {
471
+ const run = this.tail.then(async () => {
472
+ if (this.disposed && opts?.rejectIfDisposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
473
+ const { next, result } = await fn(await this.load());
474
+ await this.save(next);
475
+ return result;
476
+ });
477
+ this.tail = run.catch(() => void 0);
478
+ return run;
479
+ }
480
+ async load() {
481
+ const raw = await this.store.get(this.storageKey);
482
+ if (!raw) return [];
483
+ try {
484
+ const parsed = JSON.parse(raw);
485
+ return Array.isArray(parsed) ? parsed : [];
486
+ } catch {
487
+ return [];
488
+ }
489
+ }
490
+ async save(ops) {
491
+ if (this.disposed) return;
492
+ if (ops.length === 0) {
493
+ await this.store.delete(this.storageKey);
494
+ return;
495
+ }
496
+ await this.store.set(this.storageKey, JSON.stringify(ops));
497
+ }
498
+ /**
499
+ * Permanently retire this queue. Detaches the auto-flush listener and marks
500
+ * it disposed so it can no longer mutate the persisted outbox. Returns once
501
+ * any in-flight flush + pending mutation chain has settled, so a caller (e.g.
502
+ * `configure()` teardown) can safely create a replacement queue knowing the
503
+ * old one will never touch storage again. Idempotent.
504
+ */
505
+ async dispose() {
506
+ this.disposed = true;
507
+ if (this.detachOnline) {
508
+ this.detachOnline();
509
+ this.detachOnline = null;
510
+ }
511
+ try {
512
+ await this.flushing;
513
+ } catch {}
514
+ await this.tail.catch(() => void 0);
515
+ }
516
+ /** Whether this queue has been disposed. */
517
+ get isDisposed() {
518
+ return this.disposed;
519
+ }
520
+ /** Append an op to the durable outbox. Returns the persisted op. */
521
+ async enqueue(op) {
522
+ if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
523
+ const full = {
524
+ ...op,
525
+ idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
526
+ enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
527
+ };
528
+ return this.mutate((ops) => ({
529
+ next: [...ops, full],
530
+ result: full
531
+ }), { rejectIfDisposed: true });
532
+ }
533
+ /** Number of ops awaiting replay. */
534
+ async pendingCount() {
535
+ return (await this.load()).length;
536
+ }
537
+ /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
538
+ async pending() {
539
+ return [...await this.load()];
540
+ }
541
+ /** Drop every queued op without replaying. */
542
+ async clear() {
543
+ await this.mutate(() => ({
544
+ next: [],
545
+ result: void 0
546
+ }));
547
+ }
548
+ /**
549
+ * Replay the outbox in FIFO order. Each op is removed only after it
550
+ * succeeds; the first failure stops the flush and retains the remainder
551
+ * (so order is preserved and nothing is dropped mid-batch). Concurrent
552
+ * calls share one in-flight flush.
553
+ */
554
+ async flush() {
555
+ if (this.disposed) return {
556
+ flushed: 0,
557
+ dropped: 0,
558
+ remaining: 0
559
+ };
560
+ if (this.flushing) return this.flushing;
561
+ this.flushing = this.runFlush().finally(() => {
562
+ this.flushing = null;
563
+ });
564
+ return this.flushing;
565
+ }
566
+ async runFlush() {
567
+ let flushed = 0;
568
+ let dropped = 0;
569
+ for (;;) {
570
+ if (this.disposed) break;
571
+ const head = (await this.load())[0];
572
+ if (!head) break;
573
+ let replayError = void 0;
574
+ let ok = false;
575
+ try {
576
+ await this.executor(head);
577
+ ok = true;
578
+ } catch (err) {
579
+ replayError = err;
580
+ }
581
+ if (!ok && !isDeterministicError(replayError)) {
582
+ await this.mutate((ops) => {
583
+ return {
584
+ next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
585
+ result: void 0
586
+ };
587
+ });
588
+ break;
589
+ }
590
+ if (!await this.mutate((ops) => {
591
+ const idx = ops.findIndex((o) => o.idempotencyKey === head.idempotencyKey);
592
+ if (idx === -1) return {
593
+ next: ops,
594
+ result: false
595
+ };
596
+ return {
597
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
598
+ result: true
599
+ };
600
+ })) break;
601
+ if (ok) flushed += 1;
602
+ else {
603
+ dropped += 1;
604
+ try {
605
+ this.onDropped?.(head, replayError);
606
+ } catch {}
607
+ }
608
+ }
609
+ const remaining = (await this.load()).length;
610
+ return {
611
+ flushed,
612
+ dropped,
613
+ remaining
614
+ };
615
+ }
616
+ /**
617
+ * Install an auto-flush listener on the runtime's `online` event so the
618
+ * queue drains the moment connectivity returns. Idempotent — a second
619
+ * call is a no-op. Returns a detach function.
620
+ */
621
+ startAutoFlush() {
622
+ if (this.detachOnline) return this.detachOnline;
623
+ const target = typeof globalThis !== "undefined" && typeof globalThis.addEventListener === "function" ? globalThis : null;
624
+ if (!target) {
625
+ this.detachOnline = () => {};
626
+ return this.detachOnline;
627
+ }
628
+ const handler = () => {
629
+ this.flush();
630
+ };
631
+ target.addEventListener("online", handler);
632
+ this.detachOnline = () => {
633
+ target.removeEventListener("online", handler);
634
+ this.detachOnline = null;
635
+ };
636
+ return this.detachOnline;
637
+ }
638
+ };
639
+ /**
640
+ * Apply a local change immediately, fire the real mutation, and roll the
641
+ * local change back if the mutation fails. Framework-agnostic: `apply` and
642
+ * `rollback` operate on whatever local view you hand them (a React state
643
+ * setter, a store, an in-memory cache).
644
+ *
645
+ * Resolves with the mutation's result on success. On failure it invokes
646
+ * `rollback` (with the value `apply` returned, so you can restore a captured
647
+ * snapshot) and rethrows the original error.
648
+ *
649
+ * @example
650
+ * await withOptimistic({
651
+ * apply: () => { const prev = todos; setTodos([...todos, draft]); return prev; },
652
+ * mutate: () => Amba.collections.insert("todos", draft),
653
+ * rollback: (prev) => setTodos(prev),
654
+ * });
655
+ */
656
+ async function withOptimistic(spec) {
657
+ const applied = spec.apply();
658
+ try {
659
+ return await spec.mutate();
660
+ } catch (err) {
661
+ try {
662
+ spec.rollback(applied);
663
+ } catch {}
664
+ throw err;
665
+ }
666
+ }
667
+
668
+ //#endregion
669
+ //#region src/push-rotation.ts
670
+ /**
671
+ * Wire a platform token-refresh subscription to the SDK's idempotent
672
+ * re-register path. On every rotation the fresh token is forwarded to
673
+ * `reRegister(token, platform)` — the same upsert the first `push.register`
674
+ * call used, so re-registering the same device is a no-op server-side.
675
+ *
676
+ * `platform` defaults to the conventional pick (`"apns"` on iOS, `"fcm"`
677
+ * elsewhere) supplied by the caller. Returns an unsubscribe function.
678
+ *
679
+ * @example
680
+ * // Expo:
681
+ * import * as Notifications from "expo-notifications";
682
+ * const detach = enableNativePushAutoRotation(
683
+ * (h) => Notifications.addPushTokenListener((t) => h(t.data)).remove,
684
+ * reRegister,
685
+ * "apns",
686
+ * );
687
+ */
688
+ function enableNativePushAutoRotation(subscribe, reRegister, platform) {
689
+ const unsubscribe = subscribe((token) => {
690
+ if (!token) return;
691
+ (async () => {
692
+ try {
693
+ await reRegister(token, platform);
694
+ } catch (err) {
695
+ if (typeof console !== "undefined") console.error("[amba] push auto-rotation re-register failed", err);
696
+ }
697
+ })();
698
+ });
699
+ return typeof unsubscribe === "function" ? unsubscribe : () => {};
700
+ }
701
+
702
+ //#endregion
703
+ //#region src/local-notifications.ts
704
+ /**
705
+ * Marker key stamped into every Amba-scheduled notification's `data`. Lets
706
+ * `listScheduledLocal` distinguish OUR notifications from any other
707
+ * `expo-notifications` schedules the host app created — so foreign
708
+ * schedules never leak into the Amba list. Mirrors the web SDK's
709
+ * `__ambaLocalId` marker.
710
+ */
711
+ const AMBA_LOCAL_ID_KEY = "__ambaLocalId";
712
+ /**
713
+ * Generate a stable local-notification id when the caller omits one.
714
+ */
715
+ function genLocalId() {
716
+ const c = globalThis.crypto;
717
+ if (c?.randomUUID) return c.randomUUID();
718
+ return `amba_local_${Date.now()}_${Math.random().toString(36).slice(2)}`;
719
+ }
720
+ /**
721
+ * Resolve the id a schedule will be filed under. A caller may omit `id`
722
+ * (we mint one) but must not pass a BLANK/whitespace id: `cancelLocal`
723
+ * rejects blank ids, so returning one in the handle would make the
724
+ * notification uncancellable. Reject blank at schedule time so every
725
+ * returned handle id round-trips through `cancelLocal`. Mirrors the web SDK.
726
+ */
727
+ function normalizeScheduleId(rawId) {
728
+ if (rawId === void 0) return genLocalId();
729
+ if (typeof rawId !== "string" || rawId.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: id, when provided, must be a non-empty string");
730
+ return rawId;
731
+ }
732
+ async function loadExpoNotifications() {
733
+ try {
734
+ const mod = await import("expo-notifications");
735
+ return mod.default ?? mod;
736
+ } catch (err) {
737
+ throw new AmbaApiError("LOCAL_NOTIFICATIONS_UNAVAILABLE", "Amba.notifications requires the `expo-notifications` peer dependency. Install it with `npx expo install expo-notifications` (works in both Expo and bare React Native apps).", err);
738
+ }
739
+ }
740
+ /**
741
+ * Translate Amba's platform-neutral trigger into the shape
742
+ * `expo-notifications` expects. We build object triggers keyed by the
743
+ * library's `SchedulableTriggerInputTypes` when present (SDK 0.27+),
744
+ * falling back to the legacy `{ date }` / `{ seconds }` shorthand the
745
+ * older API accepts — both forms remain supported by current releases.
746
+ */
747
+ function buildExpoTrigger(input, Notifications) {
748
+ const types = Notifications.SchedulableTriggerInputTypes;
749
+ const trigger = input.trigger;
750
+ if (!trigger || typeof trigger !== "object") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger must be { at: Date } or { inSeconds: number, repeats?: boolean }");
751
+ if ("at" in trigger) {
752
+ const at = trigger.at instanceof Date ? trigger.at : new Date(trigger.at);
753
+ if (Number.isNaN(at.getTime())) throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger.at must be a valid Date");
754
+ return types ? {
755
+ type: types.DATE,
756
+ date: at
757
+ } : { date: at };
758
+ }
759
+ if ("inSeconds" in trigger) {
760
+ const seconds = trigger.inSeconds;
761
+ if (!Number.isFinite(seconds) || seconds < 0) throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger.inSeconds must be a non-negative number");
762
+ const repeats = "repeats" in trigger ? Boolean(trigger.repeats) : false;
763
+ return types ? {
764
+ type: types.TIME_INTERVAL,
765
+ seconds,
766
+ repeats
767
+ } : {
768
+ seconds,
769
+ repeats
770
+ };
771
+ }
772
+ throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger must be { at: Date } or { inSeconds: number, repeats?: boolean }");
773
+ }
774
+ function validateContent(input) {
775
+ if (!input || typeof input.title !== "string" || input.title.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: title is required");
776
+ if (typeof input.body !== "string") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: body must be a string");
777
+ }
778
+ /**
779
+ * The `notifications` namespace. Shared by `@layers/amba-react-native`
780
+ * and (via re-export) `@layers/amba-expo`.
781
+ */
782
+ const notifications = {
783
+ async scheduleLocal(input) {
784
+ validateContent(input);
785
+ const id = normalizeScheduleId(input.id);
786
+ const Notifications = await loadExpoNotifications();
787
+ const trigger = buildExpoTrigger(input, Notifications);
788
+ const existing = await Notifications.getPermissionsAsync();
789
+ let granted = existing.granted ?? existing.status === "granted";
790
+ if (!granted) {
791
+ const requested = await Notifications.requestPermissionsAsync();
792
+ granted = requested.granted ?? requested.status === "granted";
793
+ }
794
+ if (!granted) throw new AmbaApiError("LOCAL_NOTIFICATIONS_PERMISSION_DENIED", "user denied notification permission — local notifications cannot be scheduled");
795
+ return { id: await Notifications.scheduleNotificationAsync({
796
+ identifier: id,
797
+ content: {
798
+ title: input.title,
799
+ body: input.body,
800
+ data: {
801
+ ...input.data,
802
+ [AMBA_LOCAL_ID_KEY]: id
803
+ }
804
+ },
805
+ trigger
806
+ }) || id };
807
+ },
808
+ async cancelLocal(id) {
809
+ if (typeof id !== "string" || id.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.cancelLocal: id is required");
810
+ await (await loadExpoNotifications()).cancelScheduledNotificationAsync(id);
811
+ },
812
+ async listScheduledLocal() {
813
+ const scheduled = await (await loadExpoNotifications()).getAllScheduledNotificationsAsync();
814
+ const out = [];
815
+ for (const n of scheduled) {
816
+ const data = n.content?.data ?? {};
817
+ const markerId = data[AMBA_LOCAL_ID_KEY];
818
+ if (!markerId) continue;
819
+ out.push({
820
+ id: markerId,
821
+ title: n.content?.title ?? "",
822
+ body: n.content?.body ?? "",
823
+ data
824
+ });
825
+ }
826
+ return out;
827
+ }
828
+ };
829
+
830
+ //#endregion
831
+ //#region src/realtime.ts
832
+ const INITIAL_BACKOFF_MS = 500;
833
+ const MAX_BACKOFF_MS = 15e3;
834
+ const DEFAULT_API_URL = "https://api.amba.dev";
835
+ const DEFAULT_REALTIME_URL = "https://realtime.amba.host";
836
+ /**
837
+ * Resolve the base URL for live-update connections. Long-lived streams are
838
+ * served from a dedicated realtime host (no request-duration ceiling), so
839
+ * when the SDK points at the default production API the stream connects
840
+ * there instead. Any custom `apiUrl` (dev, staging, self-hosted, proxy)
841
+ * keeps live updates on that same origin — override semantics unchanged.
842
+ */
843
+ function resolveRealtimeBaseUrl(baseUrl) {
844
+ return baseUrl.replace(/\/+$/, "") === DEFAULT_API_URL ? DEFAULT_REALTIME_URL : baseUrl;
845
+ }
846
+ /**
847
+ * Builder returned by `Amba.collection(name)`. Chain `.where(...)` to
848
+ * narrow, then `.subscribe(cb)` to start streaming.
849
+ */
850
+ var CollectionSubscription = class {
851
+ filters = {};
852
+ constructor(ctx, name) {
853
+ this.ctx = ctx;
854
+ this.name = name;
855
+ }
856
+ /** Add an equality filter. Chainable. All filters must match (AND). */
857
+ where(column, value) {
858
+ this.filters[column] = value;
859
+ return this;
860
+ }
861
+ /**
862
+ * Start listening. `cb` fires once per matching row change. Returns an
863
+ * unsubscribe handle — call it to close the connection.
864
+ */
865
+ subscribe(cb, options = {}) {
866
+ if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
867
+ const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
868
+ return openStream({
869
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
870
+ authHeaders: this.ctx.authHeaders,
871
+ onChange: cb,
872
+ options,
873
+ parse: parseRowChangeFrame
874
+ });
875
+ }
876
+ };
877
+ /**
878
+ * Builder returned by `Amba.messaging.conversation(id)`. Call `.subscribe(cb)`
879
+ * to receive new messages live instead of polling.
880
+ *
881
+ * Delivery is scoped to conversation participants — you receive messages for
882
+ * a conversation only if your session is a member of it. Reconnects with the
883
+ * same exponential backoff as collection subscriptions.
884
+ */
885
+ var ConversationSubscription = class {
886
+ constructor(ctx, conversationId) {
887
+ this.ctx = ctx;
888
+ this.conversationId = conversationId;
889
+ }
890
+ subscribe(cb, options = {}) {
891
+ if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
892
+ return openStream({
893
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
894
+ authHeaders: this.ctx.authHeaders,
895
+ onChange: cb,
896
+ options,
897
+ parse: parseRowChangeFrame
898
+ });
899
+ }
900
+ };
901
+ /**
902
+ * Builder returned by `Amba.gamification`. Call `.subscribe(cb)` to receive the
903
+ * signed-in user's xp / level / achievement / streak changes live instead of
904
+ * polling.
905
+ *
906
+ * Delivery is scoped to the signed-in user — you only ever receive your own
907
+ * gamification changes, never another user's. Reconnects with the same
908
+ * exponential backoff as collection subscriptions.
909
+ */
910
+ var GamificationSubscription = class {
911
+ constructor(ctx) {
912
+ this.ctx = ctx;
913
+ }
914
+ subscribe(cb, options = {}) {
915
+ if (!this.ctx.authHeaders()) throw new Error("Amba.gamification.subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
916
+ return openStream({
917
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/gamification`,
918
+ authHeaders: this.ctx.authHeaders,
919
+ onChange: cb,
920
+ options,
921
+ parse: parseGamificationFrame
922
+ });
923
+ }
924
+ };
925
+ /**
926
+ * Open a long-lived event stream and dispatch parsed events to `onChange`.
927
+ * Reconnects with exponential backoff until the returned unsubscribe handle
928
+ * is invoked. Chooses the XHR incremental-text transport (RN-native) when
929
+ * available, else the streamed-`fetch`-body transport (RN-for-web / Node).
930
+ */
931
+ function openStream(args) {
932
+ let stopped = false;
933
+ let backoff = INITIAL_BACKOFF_MS;
934
+ let closeActive = null;
935
+ let settleActive = null;
936
+ const run = async () => {
937
+ while (!stopped) {
938
+ const headers = args.authHeaders();
939
+ if (!headers) {
940
+ args.options.onError?.(/* @__PURE__ */ new Error("no active session"));
941
+ break;
942
+ }
943
+ try {
944
+ await new Promise((resolve, reject) => {
945
+ settleActive = resolve;
946
+ const onFirstFrame = () => {
947
+ backoff = INITIAL_BACKOFF_MS;
948
+ args.options.onOpen?.();
949
+ };
950
+ closeActive = connectOnce({
951
+ url: args.url,
952
+ headers,
953
+ onFrame: (frame) => {
954
+ const ev = args.parse(frame);
955
+ if (ev !== null) args.onChange(ev);
956
+ },
957
+ onFirstFrame,
958
+ onDone: resolve,
959
+ onError: reject
960
+ });
961
+ });
962
+ } catch (err) {
963
+ if (stopped) break;
964
+ args.options.onError?.(err);
965
+ } finally {
966
+ closeActive?.();
967
+ closeActive = null;
968
+ settleActive = null;
969
+ }
970
+ if (stopped) break;
971
+ await delay(backoff);
972
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
973
+ }
974
+ };
975
+ run();
976
+ return () => {
977
+ stopped = true;
978
+ closeActive?.();
979
+ settleActive?.();
980
+ };
981
+ }
982
+ /**
983
+ * Establish a single connection using the best available transport. Returns
984
+ * an abort function. The XHR transport is preferred on React Native; the
985
+ * fetch-stream transport covers RN-for-web and any runtime exposing a
986
+ * readable `response.body`.
987
+ */
988
+ function connectOnce(args) {
989
+ if (typeof XMLHttpRequest !== "undefined") return connectXhr(args, XMLHttpRequest);
990
+ return connectFetchStream(args);
991
+ }
992
+ /**
993
+ * Incremental-`responseText` transport via XMLHttpRequest — the RN-native
994
+ * path. `onreadystatechange` fires at readyState 3 (LOADING) as bytes
995
+ * stream in; we track how much of `responseText` we've already parsed and
996
+ * extract any newly-completed `\n\n`-delimited frames.
997
+ */
998
+ function connectXhr(args, XHR) {
999
+ const xhr = new XHR();
1000
+ let consumed = 0;
1001
+ let buffer = "";
1002
+ let sawFrame = false;
1003
+ let aborted = false;
1004
+ const drain = () => {
1005
+ const text = xhr.responseText;
1006
+ if (text.length <= consumed) return;
1007
+ buffer += text.slice(consumed);
1008
+ consumed = text.length;
1009
+ let idx;
1010
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
1011
+ const frame = buffer.slice(0, idx);
1012
+ buffer = buffer.slice(idx + 2);
1013
+ if (!sawFrame) {
1014
+ sawFrame = true;
1015
+ args.onFirstFrame();
1016
+ }
1017
+ args.onFrame(frame);
1018
+ }
1019
+ };
1020
+ xhr.open("GET", args.url, true);
1021
+ for (const [k, v] of Object.entries(args.headers)) xhr.setRequestHeader(k, v);
1022
+ xhr.setRequestHeader("Accept", "text/event-stream");
1023
+ xhr.onreadystatechange = () => {
1024
+ if (aborted) return;
1025
+ if (xhr.readyState === 3) {
1026
+ if (xhr.status >= 400) return;
1027
+ drain();
1028
+ }
1029
+ };
1030
+ xhr.onload = () => {
1031
+ if (aborted) return;
1032
+ if (xhr.status >= 200 && xhr.status < 300) {
1033
+ drain();
1034
+ args.onDone();
1035
+ } else args.onError(/* @__PURE__ */ new Error(`realtime connect failed: HTTP ${xhr.status}`));
1036
+ };
1037
+ xhr.onerror = () => {
1038
+ if (aborted) return;
1039
+ args.onError(/* @__PURE__ */ new Error("realtime transport error"));
1040
+ };
1041
+ xhr.ontimeout = () => {
1042
+ if (aborted) return;
1043
+ args.onError(/* @__PURE__ */ new Error("realtime connection timed out"));
1044
+ };
1045
+ try {
1046
+ xhr.send();
1047
+ } catch (err) {
1048
+ args.onError(err);
1049
+ }
1050
+ return () => {
1051
+ aborted = true;
1052
+ try {
1053
+ xhr.abort();
1054
+ } catch {}
1055
+ };
1056
+ }
1057
+ /**
1058
+ * Streamed-`fetch`-body transport — used when XHR isn't present (RN-for-web,
1059
+ * Node). Identical wire handling to web/node's `consume`.
1060
+ */
1061
+ function connectFetchStream(args) {
1062
+ const controller = new AbortController();
1063
+ let aborted = false;
1064
+ (async () => {
1065
+ try {
1066
+ const resp = await fetch(args.url, {
1067
+ method: "GET",
1068
+ headers: {
1069
+ ...args.headers,
1070
+ Accept: "text/event-stream"
1071
+ },
1072
+ signal: controller.signal
1073
+ });
1074
+ if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
1075
+ const reader = resp.body.getReader();
1076
+ const decoder = new TextDecoder();
1077
+ let buffer = "";
1078
+ let sawFrame = false;
1079
+ for (;;) {
1080
+ const { done, value } = await reader.read();
1081
+ if (done) break;
1082
+ buffer += decoder.decode(value, { stream: true });
1083
+ let idx;
1084
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
1085
+ const frame = buffer.slice(0, idx);
1086
+ buffer = buffer.slice(idx + 2);
1087
+ if (!sawFrame) {
1088
+ sawFrame = true;
1089
+ args.onFirstFrame();
1090
+ }
1091
+ args.onFrame(frame);
1092
+ }
1093
+ }
1094
+ if (!aborted) args.onDone();
1095
+ } catch (err) {
1096
+ if (!aborted) args.onError(err);
1097
+ }
1098
+ })();
1099
+ return () => {
1100
+ aborted = true;
1101
+ controller.abort();
1102
+ };
1103
+ }
1104
+ /** Pull the `event:` name + joined `data:` lines out of one raw frame. */
1105
+ function readFrame(frame) {
1106
+ let event = "";
1107
+ const dataLines = [];
1108
+ for (const line of frame.split("\n")) {
1109
+ if (line.startsWith(":")) continue;
1110
+ if (line.startsWith("event:")) event = line.slice(6).trim();
1111
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
1112
+ }
1113
+ return {
1114
+ event,
1115
+ data: dataLines.join("\n")
1116
+ };
1117
+ }
1118
+ function parseRowChangeFrame(frame) {
1119
+ const { event, data } = readFrame(frame);
1120
+ if (event !== "change") return null;
1121
+ try {
1122
+ const payload = JSON.parse(data);
1123
+ return {
1124
+ op: payload.op,
1125
+ row: payload.row,
1126
+ truncated: payload.truncated
1127
+ };
1128
+ } catch {
1129
+ return null;
1130
+ }
1131
+ }
1132
+ function parseGamificationFrame(frame) {
1133
+ const { event, data } = readFrame(frame);
1134
+ if (event !== "change") return null;
1135
+ try {
1136
+ const payload = JSON.parse(data);
1137
+ return {
1138
+ kind: payload.kind,
1139
+ data: payload.data,
1140
+ truncated: payload.truncated
1141
+ };
1142
+ } catch {
1143
+ return null;
1144
+ }
1145
+ }
1146
+ function delay(ms) {
1147
+ return new Promise((resolve) => setTimeout(resolve, ms));
1148
+ }
1149
+
354
1150
  //#endregion
355
1151
  //#region src/index.ts
356
1152
  /** SDK semver — burned in at build time. Bump alongside package.json. */
357
- const SDK_VERSION = "4.0.3";
1153
+ const SDK_VERSION = "4.0.5";
358
1154
  /**
359
1155
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
360
1156
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
@@ -362,10 +1158,33 @@ const SDK_VERSION = "4.0.3";
362
1158
  */
363
1159
  const DEFAULT_FUNCTIONS_HOST = "fn.amba.host";
364
1160
  let state = null;
1161
+ /**
1162
+ * Monotonic token identifying the current offline activation. Bumped on every
1163
+ * `offline.enable()` AND every `configure()`, captured by each `enable()` into
1164
+ * its disable callback. A disabler only acts while the token still matches — so
1165
+ * a stale disabler (held across a re-`enable()` or a reconfigure) no-ops
1166
+ * instead of disabling the now-active outbox.
1167
+ */
1168
+ let _offlineEnableToken = 0;
365
1169
  function getState() {
366
1170
  if (!state) throw new AmbaApiError("NOT_INITIALIZED", "@layers/amba-react-native: SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start.");
367
1171
  return state;
368
1172
  }
1173
+ /**
1174
+ * Build the auth headers for a realtime subscribe connection, or `null`
1175
+ * when the SDK isn't configured or there's no live session. Read lazily at
1176
+ * connect time so reconnects pick up a rotated session token (the identity
1177
+ * store holds the canonical, auto-refreshed copy).
1178
+ */
1179
+ function realtimeAuthHeaders() {
1180
+ if (!state) return null;
1181
+ const token = state.identity.getSessionToken();
1182
+ if (!token) return null;
1183
+ return {
1184
+ "X-Api-Key": state.clientKey,
1185
+ Authorization: `Bearer ${token}`
1186
+ };
1187
+ }
369
1188
  const authSubscribers = /* @__PURE__ */ new Set();
370
1189
  function snapshotSession(user) {
371
1190
  if (!user) return null;
@@ -491,6 +1310,8 @@ const P = {
491
1310
  pushSubscribe: "/v1/client/push/subscribe",
492
1311
  pushUnsubscribe: "/v1/client/push/unsubscribe",
493
1312
  entitlements: "/v1/client/entitlements",
1313
+ entitlementsRestore: "/v1/client/entitlements/restore",
1314
+ offerings: "/v1/client/offerings",
494
1315
  aiAnthropic: "/v1/client/ai/anthropic/messages",
495
1316
  config: "/v1/client/config",
496
1317
  flags: "/v1/client/flags",
@@ -536,6 +1357,74 @@ async function resolveFunctionUrl(s, name) {
536
1357
  return `https://${s.resolvedFunctionsProjectId}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
537
1358
  }
538
1359
  /**
1360
+ * Bridge the AsyncStorage-backed persistence to the {@link KvStore} the
1361
+ * outbox persists through. Namespaced under the configured storage prefix so
1362
+ * multiple projects in one app don't collide.
1363
+ */
1364
+ function offlineStore(s) {
1365
+ return {
1366
+ get: (key) => s.persistence.read(key),
1367
+ set: (key, value) => s.persistence.write(key, value),
1368
+ delete: (key) => s.persistence.delete(key)
1369
+ };
1370
+ }
1371
+ /**
1372
+ * Replay one queued mutation against the live API, forwarding the op's
1373
+ * idempotency key on collection inserts so a write that landed before the
1374
+ * connection dropped is never double-applied (API migration 056).
1375
+ */
1376
+ async function replayOutboxOp(s, op) {
1377
+ switch (op.kind) {
1378
+ case "collection.insert":
1379
+ await s.http.post(`${P.collections}/${encodePathSegment(op.collection)}`, {
1380
+ ...op.payload ?? {},
1381
+ idempotency_key: op.idempotencyKey
1382
+ });
1383
+ return;
1384
+ case "collection.update":
1385
+ await s.http.patch(`${P.collections}/${encodePathSegment(op.collection)}/${encodePathSegment(op.id)}`, { set: op.payload ?? {} });
1386
+ return;
1387
+ case "collection.delete":
1388
+ await s.http.delete(`${P.collections}/${encodePathSegment(op.collection)}/${encodePathSegment(op.id)}`);
1389
+ return;
1390
+ case "track":
1391
+ try {
1392
+ await s.http.post(P.events, {
1393
+ event: op.event,
1394
+ properties: op.properties ?? void 0,
1395
+ anonymous_id: s.identity.getAnonymousId(),
1396
+ timestamp: op.occurredAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1397
+ ...op.telemetry ? { telemetry: true } : {}
1398
+ });
1399
+ } catch (err) {
1400
+ const code = err?.code;
1401
+ if (code === "UNAUTHORIZED" || code === "FORBIDDEN") throw new AmbaApiError("NETWORK_ERROR", "track replay deferred: no valid session");
1402
+ throw err;
1403
+ }
1404
+ return;
1405
+ }
1406
+ }
1407
+ function getOfflineQueue(s) {
1408
+ if (!s.offlineQueue) s.offlineQueue = new OfflineQueue({
1409
+ store: offlineStore(s),
1410
+ storageKey: `offline_outbox:${s.projectId}`,
1411
+ executor: (op) => replayOutboxOp(s, op),
1412
+ onDropped: (op, err) => {
1413
+ if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1414
+ }
1415
+ });
1416
+ return s.offlineQueue;
1417
+ }
1418
+ async function insertDirect(s, name, row) {
1419
+ return (await s.http.post(`${P.collections}/${encodePathSegment(name)}`, row)).data;
1420
+ }
1421
+ async function updateDirect(s, name, id, set) {
1422
+ return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
1423
+ }
1424
+ async function deleteDirect(s, name, id) {
1425
+ return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
1426
+ }
1427
+ /**
539
1428
  * Top-level SDK namespace for React Native.
540
1429
  */
541
1430
  const Amba = {
@@ -547,23 +1436,32 @@ const Amba = {
547
1436
  const identity = await IdentityStore.create(persistence);
548
1437
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
549
1438
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
1439
+ const http = new AmbaHttpClient({
1440
+ apiKey: config.clientKey,
1441
+ baseUrl,
1442
+ sdkPlatform: "react-native",
1443
+ sdkVersionHeader,
1444
+ identity,
1445
+ ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
1446
+ if (pathSkipsLazySession(path)) return;
1447
+ await ensureAnonymousSession(getState());
1448
+ }
1449
+ });
1450
+ const prior = state;
1451
+ _offlineEnableToken += 1;
1452
+ if (prior?.offlineQueue) await prior.offlineQueue.dispose();
550
1453
  state = {
551
- http: new AmbaHttpClient({
552
- apiKey: config.clientKey,
553
- baseUrl,
554
- sdkPlatform: "react-native",
555
- sdkVersionHeader,
556
- identity,
557
- ensureSession: config.autoAnonymousSession === false ? void 0 : async (path) => {
558
- if (pathSkipsLazySession(path)) return;
559
- await ensureAnonymousSession(getState());
560
- }
561
- }),
1454
+ http,
562
1455
  identity,
563
1456
  debug: config.debug ?? false,
564
1457
  projectId: config.projectId,
1458
+ clientKey: config.clientKey,
1459
+ baseUrl,
565
1460
  functionsBaseUrl: config.functionsBaseUrl ?? null,
566
- resolvedFunctionsProjectId: null
1461
+ resolvedFunctionsProjectId: null,
1462
+ persistence,
1463
+ offlineEnabled: false,
1464
+ offlineQueue: null
567
1465
  };
568
1466
  },
569
1467
  _reset: () => {
@@ -585,16 +1483,41 @@ const Amba = {
585
1483
  setDebug: (enabled) => {
586
1484
  getState().debug = enabled;
587
1485
  },
1486
+ collection: (name) => new CollectionSubscription({
1487
+ baseUrl: getState().baseUrl,
1488
+ authHeaders: realtimeAuthHeaders
1489
+ }, name),
1490
+ get gamification() {
1491
+ return new GamificationSubscription({
1492
+ baseUrl: getState().baseUrl,
1493
+ authHeaders: realtimeAuthHeaders
1494
+ });
1495
+ },
588
1496
  events: { track: async (event, properties, options) => {
589
1497
  const s = getState();
1498
+ const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
590
1499
  const body = {
591
1500
  event,
592
1501
  properties: properties ?? void 0,
593
1502
  anonymous_id: s.identity.getAnonymousId(),
594
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1503
+ timestamp: occurredAt
595
1504
  };
596
1505
  if (options?.telemetry) body["telemetry"] = true;
597
- await s.http.post(P.events, body);
1506
+ try {
1507
+ await s.http.post(P.events, body);
1508
+ } catch (err) {
1509
+ if (s.offlineEnabled && isOfflineError(err)) {
1510
+ await getOfflineQueue(s).enqueue({
1511
+ kind: "track",
1512
+ event,
1513
+ properties,
1514
+ telemetry: options?.telemetry,
1515
+ occurredAt
1516
+ });
1517
+ return;
1518
+ }
1519
+ throw err;
1520
+ }
598
1521
  } },
599
1522
  auth: {
600
1523
  signInAnonymously: async () => {
@@ -795,6 +1718,23 @@ const Amba = {
795
1718
  return (await getState().http.get(P.sync, query)).data;
796
1719
  }
797
1720
  },
1721
+ offline: {
1722
+ enable: () => {
1723
+ const s = getState();
1724
+ s.offlineEnabled = true;
1725
+ _offlineEnableToken += 1;
1726
+ const token = _offlineEnableToken;
1727
+ return () => {
1728
+ if (_offlineEnableToken !== token || state !== s) return;
1729
+ s.offlineEnabled = false;
1730
+ };
1731
+ },
1732
+ isEnabled: () => getState().offlineEnabled,
1733
+ pendingCount: async () => getOfflineQueue(getState()).pendingCount(),
1734
+ pending: async () => getOfflineQueue(getState()).pending(),
1735
+ flush: async () => getOfflineQueue(getState()).flush(),
1736
+ clear: async () => getOfflineQueue(getState()).clear()
1737
+ },
798
1738
  collections: {
799
1739
  find: async (name, options = {}) => {
800
1740
  return getState().http.post(`${P.collections}/${encodePathSegment(name)}/find`, options);
@@ -817,14 +1757,86 @@ const Amba = {
817
1757
  return (await s.http.get(`${P.collections}/${encodePathSegment(name)}/count`, query)).data.count;
818
1758
  },
819
1759
  insert: async (name, row) => {
820
- return (await getState().http.post(`${P.collections}/${encodePathSegment(name)}`, row)).data;
1760
+ const s = getState();
1761
+ if (s.offlineEnabled) {
1762
+ const idempotencyKey = (typeof row["idempotency_key"] === "string" ? row["idempotency_key"] : void 0) ?? newIdempotencyKey();
1763
+ try {
1764
+ return (await s.http.post(`${P.collections}/${encodePathSegment(name)}`, {
1765
+ ...row,
1766
+ idempotency_key: idempotencyKey
1767
+ })).data;
1768
+ } catch (err) {
1769
+ if (isOfflineError(err)) {
1770
+ await getOfflineQueue(s).enqueue({
1771
+ kind: "collection.insert",
1772
+ collection: name,
1773
+ payload: row,
1774
+ idempotencyKey
1775
+ });
1776
+ return {
1777
+ ...row,
1778
+ _amba_pending: true
1779
+ };
1780
+ }
1781
+ throw err;
1782
+ }
1783
+ }
1784
+ return (await s.http.post(`${P.collections}/${encodePathSegment(name)}`, row)).data;
821
1785
  },
822
1786
  update: async (name, id, set) => {
823
- return (await getState().http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
1787
+ const s = getState();
1788
+ if (s.offlineEnabled) try {
1789
+ return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
1790
+ } catch (err) {
1791
+ if (isOfflineError(err)) {
1792
+ await getOfflineQueue(s).enqueue({
1793
+ kind: "collection.update",
1794
+ collection: name,
1795
+ id,
1796
+ payload: set
1797
+ });
1798
+ return {
1799
+ id,
1800
+ ...set,
1801
+ _amba_pending: true
1802
+ };
1803
+ }
1804
+ throw err;
1805
+ }
1806
+ return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
824
1807
  },
825
1808
  delete: async (name, id) => {
826
- return await getState().http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
827
- },
1809
+ const s = getState();
1810
+ if (s.offlineEnabled) try {
1811
+ return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
1812
+ } catch (err) {
1813
+ if (isOfflineError(err)) {
1814
+ await getOfflineQueue(s).enqueue({
1815
+ kind: "collection.delete",
1816
+ collection: name,
1817
+ id
1818
+ });
1819
+ return { data: { deleted: true } };
1820
+ }
1821
+ throw err;
1822
+ }
1823
+ return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
1824
+ },
1825
+ optimisticInsert: async (name, row, view) => withOptimistic({
1826
+ apply: view.apply,
1827
+ mutate: () => insertDirect(getState(), name, row),
1828
+ rollback: view.rollback
1829
+ }),
1830
+ optimisticUpdate: async (name, id, set, view) => withOptimistic({
1831
+ apply: view.apply,
1832
+ mutate: () => updateDirect(getState(), name, id, set),
1833
+ rollback: view.rollback
1834
+ }),
1835
+ optimisticDelete: async (name, id, view) => withOptimistic({
1836
+ apply: view.apply,
1837
+ mutate: () => deleteDirect(getState(), name, id),
1838
+ rollback: view.rollback
1839
+ }),
828
1840
  where: {
829
1841
  eq: (column, value) => ({
830
1842
  column,
@@ -952,16 +1964,24 @@ const Amba = {
952
1964
  },
953
1965
  unsubscribe: async (topic) => {
954
1966
  await getState().http.post(P.pushUnsubscribe, { topic });
955
- }
1967
+ },
1968
+ enableAutoRotation: (subscribe, platform = "fcm") => enableNativePushAutoRotation(subscribe, (token, p) => Amba.push.register(token, p), platform)
956
1969
  },
1970
+ notifications,
957
1971
  entitlements: {
958
1972
  list: async () => {
959
1973
  return (await getState().http.get(P.entitlements)).data;
960
1974
  },
961
- has: async (name) => {
962
- return (await getState().http.get(P.entitlements, { active_only: "true" })).data.some((e) => e.name === name && e.is_active);
1975
+ has: async (entitlementId) => {
1976
+ return (await getState().http.get(P.entitlements, { active_only: "true" })).data.some((e) => e.entitlement_id === entitlementId && e.is_active);
1977
+ },
1978
+ restore: async () => {
1979
+ return (await getState().http.post(P.entitlementsRestore, {})).data;
963
1980
  }
964
1981
  },
1982
+ offerings: async (offeringId) => {
1983
+ return (await getState().http.get(P.offerings, offeringId ? { offering_id: offeringId } : void 0)).data;
1984
+ },
965
1985
  ai: { anthropic: { messages: { create: async (request) => {
966
1986
  const env = await getState().http.post(P.aiAnthropic, request);
967
1987
  if (!env || typeof env !== "object" || env.data === void 0) throw new AmbaApiError("MALFORMED_RESPONSE", "AI response was not wrapped in the expected { data } envelope", env);
@@ -1207,6 +2227,10 @@ const Amba = {
1207
2227
  }
1208
2228
  },
1209
2229
  messaging: {
2230
+ conversation: (conversationId) => new ConversationSubscription({
2231
+ baseUrl: getState().baseUrl,
2232
+ authHeaders: realtimeAuthHeaders
2233
+ }, conversationId),
1210
2234
  createConversation: async (request) => {
1211
2235
  return (await getState().http.post(`${P.messaging}/conversations`, request)).data;
1212
2236
  },
@@ -1352,5 +2376,9 @@ const Amba = {
1352
2376
  //#endregion
1353
2377
  exports.Amba = Amba;
1354
2378
  exports.AmbaApiError = AmbaApiError;
2379
+ exports.CollectionSubscription = CollectionSubscription;
2380
+ exports.ConversationSubscription = ConversationSubscription;
2381
+ exports.GamificationSubscription = GamificationSubscription;
1355
2382
  exports.SDK_VERSION = SDK_VERSION;
2383
+ exports.withOptimistic = withOptimistic;
1356
2384
  //# sourceMappingURL=index.cjs.map