@layers/amba-react-native 4.0.2 → 4.0.4

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