@layers/amba-react-native 4.0.6 → 4.0.8

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
@@ -42,6 +42,10 @@ var AsyncStoragePersistence = class {
42
42
  constructor(prefix) {
43
43
  this.prefix = prefix;
44
44
  }
45
+ /** Batching promises durable enqueue, so memory-only fallback is invalid. */
46
+ async assertDurable() {
47
+ if (!await getStorage()) throw new Error("Amba event batching requires @react-native-async-storage/async-storage");
48
+ }
45
49
  async hydrate() {}
46
50
  async read(key) {
47
51
  const fullKey = `${this.prefix}:${key}`;
@@ -54,15 +58,15 @@ var AsyncStoragePersistence = class {
54
58
  }
55
59
  async write(key, value) {
56
60
  const fullKey = `${this.prefix}:${key}`;
57
- this.memoryCache.set(fullKey, value);
58
61
  const storage = await getStorage();
59
62
  if (storage) await storage.setItem(fullKey, value);
63
+ this.memoryCache.set(fullKey, value);
60
64
  }
61
65
  async delete(key) {
62
66
  const fullKey = `${this.prefix}:${key}`;
63
- this.memoryCache.delete(fullKey);
64
67
  const storage = await getStorage();
65
68
  if (storage) await storage.removeItem(fullKey);
69
+ this.memoryCache.delete(fullKey);
66
70
  }
67
71
  };
68
72
 
@@ -134,18 +138,24 @@ var AmbaHttpClient = class {
134
138
  async post(path, body, query) {
135
139
  return this.request("POST", path, body, query);
136
140
  }
141
+ /** Send a queued event with the session captured for its original user. */
142
+ async postWithSession(path, body, sessionToken) {
143
+ return this.request("POST", path, body, void 0, sessionToken);
144
+ }
137
145
  async patch(path, body) {
138
146
  return this.request("PATCH", path, body);
139
147
  }
140
148
  async delete(path) {
141
149
  return this.request("DELETE", path);
142
150
  }
143
- async request(method, path, body, query) {
144
- if (this.ensureSession && !this.identity.getSessionToken()) await this.ensureSession(path);
151
+ async request(method, path, body, query, sessionToken) {
152
+ if (this.ensureSession && !sessionToken && !this.identity.getSessionToken()) await this.ensureSession(path);
145
153
  const url = this.url(path, query);
154
+ const headers = this.buildHeaders();
155
+ if (sessionToken) headers.Authorization = `Bearer ${sessionToken}`;
146
156
  const init = {
147
157
  method,
148
- headers: this.buildHeaders(),
158
+ headers,
149
159
  body: body === void 0 ? void 0 : JSON.stringify(body ?? {})
150
160
  };
151
161
  let response;
@@ -253,8 +263,23 @@ var IdentityStore = class IdentityStore {
253
263
  sessionToken = null;
254
264
  refreshToken = null;
255
265
  expiresAt = null;
256
- constructor(persistence, seed) {
266
+ /**
267
+ * Credentials an earlier SDK version left in the AsyncStorage record, with
268
+ * the user they belong to. They stay on disk until the vault holds the
269
+ * session, so a vault write that fails or is skipped (or a process killed
270
+ * between the two writes) never loses the only stored copy.
271
+ */
272
+ plaintext;
273
+ /**
274
+ * Set while the vault could not be read. Writes are held back until a read
275
+ * succeeds, so a session this launch never loaded is not overwritten.
276
+ */
277
+ vaultUnreadable;
278
+ constructor(persistence, seed, vault, plaintext, vaultUnreadable) {
257
279
  this.persistence = persistence;
280
+ this.vault = vault;
281
+ this.plaintext = plaintext;
282
+ this.vaultUnreadable = vaultUnreadable;
258
283
  this.anonymousId = seed.anonymous_id;
259
284
  this.appUserId = seed.app_user_id;
260
285
  this.sessionToken = seed.session_token;
@@ -262,13 +287,19 @@ var IdentityStore = class IdentityStore {
262
287
  this.expiresAt = seed.expires_at;
263
288
  }
264
289
  /**
265
- * Build a store, hydrating any prior identity from AsyncStorage.
266
- * If no prior identity exists, a fresh `anonymous_id` is minted.
267
- * Expired session tokens are dropped at hydration (the refresh token
268
- * survives so callers can `auth.refresh()` to mint a new pair).
290
+ * Build a store, hydrating any prior identity from AsyncStorage and the
291
+ * credential vault. If no prior identity exists, a fresh `anonymous_id`
292
+ * is minted. Expired session tokens are dropped at hydration (the refresh
293
+ * token survives so callers can `auth.refresh()` to mint a new pair).
294
+ *
295
+ * With a vault, credentials an earlier SDK version left in the
296
+ * AsyncStorage record are used for this launch and moved into the vault;
297
+ * the AsyncStorage copy is removed once the vault write succeeds. A vault
298
+ * holding its own credentials wins over them.
269
299
  */
270
- static async create(persistence) {
300
+ static async create(persistence, vault = null) {
271
301
  const raw = await persistence.read(IDENTITY_KEY);
302
+ let recordPresent = false;
272
303
  let seed;
273
304
  if (raw) try {
274
305
  const parsed = JSON.parse(raw);
@@ -279,16 +310,42 @@ var IdentityStore = class IdentityStore {
279
310
  refresh_token: parsed.refresh_token ?? null,
280
311
  expires_at: parsed.expires_at ?? null
281
312
  };
313
+ recordPresent = typeof parsed.anonymous_id === "string";
282
314
  } catch {
283
315
  seed = freshSeed();
284
316
  }
285
317
  else seed = freshSeed();
318
+ const plaintext = vault && (seed.session_token !== null || seed.refresh_token !== null || seed.expires_at !== null) ? {
319
+ appUserId: seed.app_user_id,
320
+ slice: {
321
+ session_token: seed.session_token,
322
+ refresh_token: seed.refresh_token,
323
+ expires_at: seed.expires_at
324
+ }
325
+ } : null;
326
+ let vaultReadable = true;
327
+ if (vault) {
328
+ let fromVault = null;
329
+ try {
330
+ fromVault = await vault.read();
331
+ } catch {
332
+ vaultReadable = false;
333
+ }
334
+ const belongs = fromVault !== null && recordPresent && fromVault.anonymous_id === seed.anonymous_id;
335
+ if (fromVault && belongs) {
336
+ seed.session_token = fromVault.session_token;
337
+ seed.refresh_token = fromVault.refresh_token;
338
+ seed.expires_at = fromVault.expires_at;
339
+ if (fromVault.app_user_id != null) seed.app_user_id = fromVault.app_user_id;
340
+ }
341
+ }
342
+ if (vaultReadable && seed.session_token === null && seed.refresh_token === null) seed.app_user_id = null;
286
343
  if (isExpired(seed.expires_at)) {
287
344
  seed.session_token = null;
288
345
  seed.expires_at = null;
289
346
  }
290
- const store = new IdentityStore(persistence, seed);
291
- await store.flush();
347
+ const store = new IdentityStore(persistence, seed, vault, plaintext, !vaultReadable);
348
+ await store.flush({ hydrating: true });
292
349
  return store;
293
350
  }
294
351
  snapshot() {
@@ -328,17 +385,64 @@ var IdentityStore = class IdentityStore {
328
385
  this.refreshToken = null;
329
386
  this.expiresAt = null;
330
387
  if (rotateAnonymousId) this.anonymousId = uuidv4();
331
- await this.flush();
388
+ await this.flush({ signingOut: true });
332
389
  }
333
- async flush() {
334
- const payload = {
390
+ /**
391
+ * Persist the identity. `hydrating` marks the write that follows `create`:
392
+ * when the vault was unreadable and a retry now succeeds, the session it
393
+ * holds is adopted instead of overwritten. `signingOut` marks the write
394
+ * from `clearSession`: when the vault can't be emptied, the install moves
395
+ * to a new anonymous id so the entry left behind no longer belongs to it.
396
+ */
397
+ async flush(opts) {
398
+ if (!this.vault) {
399
+ const payload = {
400
+ anonymous_id: this.anonymousId,
401
+ app_user_id: this.appUserId,
402
+ session_token: this.sessionToken,
403
+ refresh_token: this.refreshToken,
404
+ expires_at: this.expiresAt
405
+ };
406
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
407
+ return;
408
+ }
409
+ let saved = false;
410
+ if (this.vaultUnreadable) try {
411
+ const stored = await this.vault.read();
412
+ this.vaultUnreadable = false;
413
+ if (opts?.hydrating && stored && (stored.session_token !== null || stored.refresh_token !== null) && stored.anonymous_id === this.anonymousId) {
414
+ this.sessionToken = isExpired(stored.expires_at) ? null : stored.session_token;
415
+ this.refreshToken = stored.refresh_token;
416
+ this.expiresAt = isExpired(stored.expires_at) ? null : stored.expires_at;
417
+ if (stored.app_user_id != null) this.appUserId = stored.app_user_id;
418
+ }
419
+ } catch {}
420
+ if (!this.vaultUnreadable) try {
421
+ await this.vault.write({
422
+ session_token: this.sessionToken,
423
+ refresh_token: this.refreshToken,
424
+ expires_at: this.expiresAt,
425
+ anonymous_id: this.anonymousId,
426
+ app_user_id: this.appUserId
427
+ });
428
+ saved = true;
429
+ } catch (err) {
430
+ if (typeof console !== "undefined") console.warn("[amba] could not save the session to the secure store", err);
431
+ }
432
+ if (opts?.signingOut && !saved) {
433
+ this.anonymousId = uuidv4();
434
+ if (typeof console !== "undefined") console.warn("[amba] could not clear the secure store at sign-out; starting a new anonymous id so the stored session can't be restored");
435
+ }
436
+ const keep = !saved && this.plaintext !== null && this.refreshToken !== null && this.plaintext.appUserId === this.appUserId && this.plaintext.slice.refresh_token === this.refreshToken ? this.plaintext.slice : null;
437
+ const record = {
335
438
  anonymous_id: this.anonymousId,
336
439
  app_user_id: this.appUserId,
337
- session_token: this.sessionToken,
338
- refresh_token: this.refreshToken,
339
- expires_at: this.expiresAt
440
+ session_token: keep?.session_token ?? null,
441
+ refresh_token: keep?.refresh_token ?? null,
442
+ expires_at: keep?.expires_at ?? null
340
443
  };
341
- await this.persistence.write(IDENTITY_KEY, JSON.stringify(payload));
444
+ await this.persistence.write(IDENTITY_KEY, JSON.stringify(record));
445
+ if (!keep) this.plaintext = null;
342
446
  }
343
447
  };
344
448
  function freshSeed() {
@@ -351,9 +455,167 @@ function freshSeed() {
351
455
  };
352
456
  }
353
457
 
458
+ //#endregion
459
+ //#region src/token-storage.ts
460
+ const TOKEN_STORAGE_MODES = [
461
+ "secure",
462
+ "memory",
463
+ "asyncStorage"
464
+ ];
465
+ function isEmpty(slice) {
466
+ return slice.session_token === null && slice.refresh_token === null && slice.expires_at === null;
467
+ }
468
+ /**
469
+ * Memory-held credentials, keyed like the keystore entry. They last as long
470
+ * as the JS runtime, so a second `Amba.configure()` (Fast Refresh, a
471
+ * re-configure) keeps the session, and a relaunch starts signed out.
472
+ */
473
+ const memorySlices = /* @__PURE__ */ new Map();
474
+ /** Credentials held in this JS runtime only. */
475
+ var MemoryTokenVault = class {
476
+ kind = "memory";
477
+ constructor(key) {
478
+ this.key = key;
479
+ }
480
+ async read() {
481
+ const slice = memorySlices.get(this.key);
482
+ return slice ? { ...slice } : null;
483
+ }
484
+ async write(slice) {
485
+ if (isEmpty(slice)) memorySlices.delete(this.key);
486
+ else memorySlices.set(this.key, { ...slice });
487
+ }
488
+ };
489
+ /**
490
+ * Keystore key for a storage prefix. Kept to `[A-Za-z0-9_.-]` because some
491
+ * secure stores (for example `expo-secure-store`) reject other characters.
492
+ */
493
+ function secureTokenKey(prefix) {
494
+ return `${prefix.replace(/[^A-Za-z0-9_.-]/g, "_")}_session_tokens_v1`;
495
+ }
496
+ /** Credentials in a keystore-backed {@link SecureTokenStorage}. */
497
+ var SecureTokenVault = class {
498
+ kind = "secure";
499
+ constructor(storage, key) {
500
+ this.storage = storage;
501
+ this.key = key;
502
+ }
503
+ async read() {
504
+ const raw = await this.storage.getItem(this.key);
505
+ if (!raw) return null;
506
+ try {
507
+ const parsed = JSON.parse(raw);
508
+ return {
509
+ session_token: parsed.session_token ?? null,
510
+ refresh_token: parsed.refresh_token ?? null,
511
+ expires_at: parsed.expires_at ?? null,
512
+ anonymous_id: parsed.anonymous_id ?? null,
513
+ app_user_id: parsed.app_user_id ?? null
514
+ };
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+ async write(slice) {
520
+ if (isEmpty(slice)) {
521
+ await this.storage.removeItem(this.key);
522
+ return;
523
+ }
524
+ await this.storage.setItem(this.key, JSON.stringify(slice));
525
+ }
526
+ };
527
+ function isKeychainLike(mod) {
528
+ const k = mod;
529
+ return typeof k === "object" && k !== null && typeof k.setGenericPassword === "function" && typeof k.getGenericPassword === "function" && typeof k.resetGenericPassword === "function";
530
+ }
531
+ /**
532
+ * Adapt `react-native-keychain` to {@link SecureTokenStorage}. Items use the
533
+ * `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` class on iOS: readable after the
534
+ * first unlock (so background refreshes work) and left behind by backups
535
+ * restored onto another device.
536
+ */
537
+ function keychainStorage(keychain) {
538
+ const accessible = keychain.ACCESSIBLE?.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;
539
+ return {
540
+ async getItem(key) {
541
+ const entry = await keychain.getGenericPassword({ service: key });
542
+ return entry ? entry.password : null;
543
+ },
544
+ async setItem(key, value) {
545
+ if (await keychain.setGenericPassword("amba", value, {
546
+ service: key,
547
+ ...accessible !== void 0 ? { accessible } : {}
548
+ }) === false) throw new Error("react-native-keychain refused to store the session");
549
+ },
550
+ async removeItem(key) {
551
+ if (await keychain.resetGenericPassword({ service: key }) === false && await keychain.getGenericPassword({ service: key })) throw new Error("react-native-keychain could not remove the session");
552
+ }
553
+ };
554
+ }
555
+ let cachedKeychain;
556
+ /**
557
+ * Load `react-native-keychain` when the app has it installed. Resolves null
558
+ * otherwise (module missing, or a build without its native half).
559
+ */
560
+ async function loadKeychainStorage() {
561
+ if (cachedKeychain !== void 0) return cachedKeychain;
562
+ try {
563
+ const mod = await import("react-native-keychain");
564
+ const candidate = isKeychainLike(mod) ? mod : mod.default;
565
+ cachedKeychain = isKeychainLike(candidate) ? keychainStorage(candidate) : null;
566
+ } catch {
567
+ cachedKeychain = null;
568
+ }
569
+ return cachedKeychain;
570
+ }
571
+ let memoryFallbackWarned = false;
572
+ /**
573
+ * Pick the vault for `mode`. Resolves null for `"asyncStorage"`, which keeps
574
+ * the credentials inside the AsyncStorage identity record.
575
+ */
576
+ async function resolveTokenVault(mode, prefix, secureStorage) {
577
+ if (mode === "asyncStorage") return null;
578
+ const key = secureTokenKey(prefix);
579
+ if (mode === "memory") return new MemoryTokenVault(key);
580
+ const storage = secureStorage ?? await loadKeychainStorage();
581
+ if (storage) return new SecureTokenVault(storage, key);
582
+ if (!memoryFallbackWarned && typeof console !== "undefined") {
583
+ memoryFallbackWarned = true;
584
+ console.warn("[amba] No secure token store found, so the session is kept in memory and users sign in again after every launch. Install react-native-keychain (or pass `secureStorage` to Amba.configure) to keep sessions across launches in the Keychain/Keystore.");
585
+ }
586
+ return new MemoryTokenVault(key);
587
+ }
588
+
589
+ //#endregion
590
+ //#region src/path-segment.ts
591
+ /**
592
+ * Encode one caller-supplied id, key, slug or name as a single URL path
593
+ * segment. `encodeURIComponent` escapes `/`, `?` and `#`, so the value stays
594
+ * inside its segment. `.` and `..` are refused because URL resolution treats
595
+ * them as steps to another route, and the empty string is refused because it
596
+ * collapses onto the parent route.
597
+ */
598
+ function pathSegment(value) {
599
+ if (value === "" || value === "." || value === "..") throw new AmbaApiError("VALIDATION_ERROR", `path segment must not be ${value === "" ? "empty" : `'${value}'`}`);
600
+ return encodeURIComponent(value);
601
+ }
602
+
354
603
  //#endregion
355
604
  //#region src/resilience.ts
356
605
  /**
606
+ * Whether `op` belongs to `current`. An op queued under a user id matches only
607
+ * that user id. An op queued before any session matches the same anonymous id,
608
+ * so a guest's offline write lands once that guest's session starts. An op
609
+ * with no recorded owner (queued by an SDK version before owners existed)
610
+ * matches nobody.
611
+ */
612
+ function outboxOwnerMatches(op, current) {
613
+ const owner = op.owner;
614
+ if (!owner) return false;
615
+ if (owner.appUserId) return owner.appUserId === current.appUserId;
616
+ return owner.anonymousId !== null && owner.anonymousId === current.anonymousId;
617
+ }
618
+ /**
357
619
  * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
358
620
  * exists; under Node / SSR it doesn't, so we treat "unknown" as online
359
621
  * (the executor's own error handling then decides).
@@ -437,8 +699,18 @@ var OfflineQueue = class {
437
699
  * SDK surface the loss (debug log) instead of silently discarding.
438
700
  */
439
701
  onDropped;
702
+ /**
703
+ * The identity that is signed in right now. When set, enqueue stamps it on
704
+ * each op and flush/pending only touch ops it owns.
705
+ */
706
+ currentOwner;
440
707
  flushing = null;
441
708
  /**
709
+ * Bumped by `purge()`. A replay that started before a purge sees the change
710
+ * and does not re-queue its op.
711
+ */
712
+ purgeGeneration = 0;
713
+ /**
442
714
  * Serializes every read-modify-write of the persisted outbox. Each mutation
443
715
  * chains onto the previous one's promise, so concurrent `enqueue`s — and an
444
716
  * `enqueue` racing the per-op removal inside `flush` — can never read a stale
@@ -461,6 +733,16 @@ var OfflineQueue = class {
461
733
  this.executor = opts.executor;
462
734
  this.storageKey = opts.storageKey ?? "offline_outbox";
463
735
  this.onDropped = opts.onDropped;
736
+ this.currentOwner = opts.currentOwner;
737
+ }
738
+ /**
739
+ * The ops the current owner may see and replay. Without an owner source
740
+ * every op qualifies.
741
+ */
742
+ visible(ops) {
743
+ if (!this.currentOwner) return ops;
744
+ const current = this.currentOwner();
745
+ return ops.filter((op) => outboxOwnerMatches(op, current));
464
746
  }
465
747
  /**
466
748
  * Run `fn` exclusively against the persisted outbox: it chains after every
@@ -517,28 +799,55 @@ var OfflineQueue = class {
517
799
  get isDisposed() {
518
800
  return this.disposed;
519
801
  }
520
- /** Append an op to the durable outbox. Returns the persisted op. */
521
- async enqueue(op) {
802
+ /**
803
+ * Capture the current owner and purge generation. Take it before a write's
804
+ * live attempt and pass it to {@link enqueue}.
805
+ */
806
+ ticket() {
807
+ return {
808
+ owner: this.currentOwner?.(),
809
+ generation: this.purgeGeneration
810
+ };
811
+ }
812
+ /**
813
+ * Append an op to the durable outbox. Returns the persisted op. With a
814
+ * `ticket`, the op belongs to the ticket's owner, and the enqueue rejects
815
+ * (code `OFFLINE_QUEUE_PURGED`) when the queue was purged after the ticket
816
+ * was taken, which is what a sign-out during the live attempt does.
817
+ */
818
+ async enqueue(op, ticket) {
522
819
  if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
820
+ const owner = op.owner ?? (ticket ? ticket.owner : this.currentOwner?.());
523
821
  const full = {
524
822
  ...op,
823
+ ...owner ? { owner: { ...owner } } : {},
525
824
  idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
526
825
  enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
527
826
  };
528
- return this.mutate((ops) => ({
529
- next: [...ops, full],
530
- result: full
531
- }), { rejectIfDisposed: true });
827
+ return this.mutate((ops) => {
828
+ if (ticket && ticket.generation !== this.purgeGeneration) {
829
+ const err = /* @__PURE__ */ new Error("amba offline queue was cleared by a sign-out while this write was in flight");
830
+ err.code = "OFFLINE_QUEUE_PURGED";
831
+ throw err;
832
+ }
833
+ return {
834
+ next: [...ops, full],
835
+ result: full
836
+ };
837
+ }, { rejectIfDisposed: true });
532
838
  }
533
- /** Number of ops awaiting replay. */
839
+ /** Number of the current owner's ops awaiting replay. */
534
840
  async pendingCount() {
535
- return (await this.load()).length;
841
+ return this.visible(await this.load()).length;
536
842
  }
537
- /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
843
+ /**
844
+ * Snapshot the current owner's pending ops (a copy — mutating it does not
845
+ * affect the queue). Ops queued by another user stay out of the list.
846
+ */
538
847
  async pending() {
539
- return [...await this.load()];
848
+ return [...this.visible(await this.load())];
540
849
  }
541
- /** Drop every queued op without replaying. */
850
+ /** Drop every queued op, whoever queued it, without replaying. */
542
851
  async clear() {
543
852
  await this.mutate(() => ({
544
853
  next: [],
@@ -546,10 +855,23 @@ var OfflineQueue = class {
546
855
  }));
547
856
  }
548
857
  /**
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.
858
+ * Drop every queued op, including one whose replay is in flight right now:
859
+ * that op is not re-queued when its replay fails. The SDKs call this on
860
+ * sign-out so nothing the signed-out user queued survives on the device.
861
+ */
862
+ async purge() {
863
+ this.purgeGeneration += 1;
864
+ await this.clear();
865
+ }
866
+ /**
867
+ * Replay the current owner's ops in FIFO order. Each op is removed only
868
+ * after it succeeds; the first failure stops the flush and retains the
869
+ * remainder (so order is preserved and nothing is dropped mid-batch). Ops
870
+ * another user queued stay held for that user. Ops that can never replay
871
+ * are dropped through `onDropped`: one with no recorded owner (code
872
+ * `OFFLINE_OWNER_UNKNOWN`), and one queued before any session under an
873
+ * anonymous id the install no longer has (code `OFFLINE_OWNER_GONE`).
874
+ * Concurrent calls share one in-flight flush.
553
875
  */
554
876
  async flush() {
555
877
  if (this.disposed) return {
@@ -568,8 +890,26 @@ var OfflineQueue = class {
568
890
  let dropped = 0;
569
891
  for (;;) {
570
892
  if (this.disposed) break;
571
- const head = (await this.load())[0];
893
+ const all = await this.load();
894
+ if (this.currentOwner) {
895
+ const current = this.currentOwner();
896
+ const orphan = all.find((o) => !o.owner || o.owner.appUserId === null && current.anonymousId !== null && o.owner.anonymousId !== current.anonymousId);
897
+ if (orphan) {
898
+ if (!await this.removeOp(orphan)) break;
899
+ dropped += 1;
900
+ this.reportDropped(orphan, orphan.owner ? {
901
+ code: "OFFLINE_OWNER_GONE",
902
+ message: "queued before sign-in under an anonymous id this install no longer has; not replayed"
903
+ } : {
904
+ code: "OFFLINE_OWNER_UNKNOWN",
905
+ message: "queued by an earlier SDK version without an owner; not replayed"
906
+ });
907
+ continue;
908
+ }
909
+ }
910
+ const head = this.visible(all)[0];
572
911
  if (!head) break;
912
+ const generation = this.purgeGeneration;
573
913
  let replayError = void 0;
574
914
  let ok = false;
575
915
  try {
@@ -581,32 +921,20 @@ var OfflineQueue = class {
581
921
  if (!ok && !isDeterministicError(replayError)) {
582
922
  await this.mutate((ops) => {
583
923
  return {
584
- next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
924
+ next: !ops.some((o) => o.idempotencyKey === head.idempotencyKey) && generation === this.purgeGeneration ? [head, ...ops] : ops,
585
925
  result: void 0
586
926
  };
587
927
  });
588
928
  break;
589
929
  }
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;
930
+ if (!await this.removeOp(head)) break;
601
931
  if (ok) flushed += 1;
602
932
  else {
603
933
  dropped += 1;
604
- try {
605
- this.onDropped?.(head, replayError);
606
- } catch {}
934
+ this.reportDropped(head, replayError);
607
935
  }
608
936
  }
609
- const remaining = (await this.load()).length;
937
+ const remaining = this.visible(await this.load()).length;
610
938
  return {
611
939
  flushed,
612
940
  dropped,
@@ -614,6 +942,29 @@ var OfflineQueue = class {
614
942
  };
615
943
  }
616
944
  /**
945
+ * Remove exactly `op` (by idempotency key) under the lock. Robust against a
946
+ * concurrent enqueue having shifted the array. Resolves false when the op
947
+ * is already gone.
948
+ */
949
+ removeOp(op) {
950
+ return this.mutate((ops) => {
951
+ const idx = ops.findIndex((o) => o.idempotencyKey === op.idempotencyKey);
952
+ if (idx === -1) return {
953
+ next: ops,
954
+ result: false
955
+ };
956
+ return {
957
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
958
+ result: true
959
+ };
960
+ });
961
+ }
962
+ reportDropped(op, err) {
963
+ try {
964
+ this.onDropped?.(op, err);
965
+ } catch {}
966
+ }
967
+ /**
617
968
  * Install an auto-flush listener on the runtime's `online` event so the
618
969
  * queue drains the moment connectivity returns. Idempotent — a second
619
970
  * call is a no-op. Returns a detach function.
@@ -665,6 +1016,322 @@ async function withOptimistic(spec) {
665
1016
  }
666
1017
  }
667
1018
 
1019
+ //#endregion
1020
+ //#region src/event-batch-queue.ts
1021
+ const MAX_BYTES = 512 * 1024;
1022
+ const HELD_TTL_MS = 720 * 60 * 60 * 1e3;
1023
+ const COALESCE_MS = 250;
1024
+ function byteLength(s) {
1025
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(s).length;
1026
+ return encodeURIComponent(s).replace(/%[A-Fa-f0-9]{2}|./g, "x").length;
1027
+ }
1028
+ function wire(e) {
1029
+ return {
1030
+ event: e.event,
1031
+ properties: e.properties,
1032
+ timestamp: e.timestamp,
1033
+ telemetry: e.telemetry,
1034
+ idempotency_key: e.idempotency_key
1035
+ };
1036
+ }
1037
+ function safeDrop(cb, e, reason, code) {
1038
+ try {
1039
+ cb?.({
1040
+ idempotencyKey: e.idempotency_key,
1041
+ event: e.event,
1042
+ userId: e.userId,
1043
+ reason,
1044
+ code
1045
+ });
1046
+ } catch {}
1047
+ }
1048
+ /** Durable queue owned by one configured project and storage prefix. */
1049
+ var EventBatchQueue = class {
1050
+ events = [];
1051
+ pending = [];
1052
+ writeTimer = null;
1053
+ flushTimer = null;
1054
+ retryTimer = null;
1055
+ serialized = Promise.resolve();
1056
+ inFlight = null;
1057
+ disposed = false;
1058
+ online = null;
1059
+ batchingEnabled = true;
1060
+ failures = 0;
1061
+ clearingUsers = /* @__PURE__ */ new Set();
1062
+ constructor(store, storageKey, options, identity, postBatch, postSingle) {
1063
+ this.store = store;
1064
+ this.storageKey = storageKey;
1065
+ this.options = options;
1066
+ this.identity = identity;
1067
+ this.postBatch = postBatch;
1068
+ this.postSingle = postSingle;
1069
+ }
1070
+ async start() {
1071
+ const raw = await this.store.get(this.storageKey);
1072
+ if (raw) try {
1073
+ const parsed = JSON.parse(raw);
1074
+ this.events = Array.isArray(parsed) ? parsed : [];
1075
+ } catch {
1076
+ this.events = [];
1077
+ }
1078
+ await this.expireHeld();
1079
+ if (this.events.length) this.flush();
1080
+ }
1081
+ exclusive(work) {
1082
+ const run = this.serialized.then(work);
1083
+ this.serialized = run.catch(() => void 0);
1084
+ return run;
1085
+ }
1086
+ async save() {
1087
+ if (this.events.length) await this.store.set(this.storageKey, JSON.stringify(this.events));
1088
+ else await this.store.delete(this.storageKey);
1089
+ }
1090
+ enqueue(event, properties, telemetry) {
1091
+ if (this.disposed) return Promise.reject(/* @__PURE__ */ new Error("Amba event queue disposed"));
1092
+ const id = this.identity();
1093
+ const item = {
1094
+ event,
1095
+ properties,
1096
+ telemetry,
1097
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1098
+ idempotency_key: newIdempotencyKey(),
1099
+ userId: id.sessionToken ? id.userId : null,
1100
+ anonymousId: id.anonymousId
1101
+ };
1102
+ if (!event.trim()) return Promise.reject(/* @__PURE__ */ new Error("event name must be non-empty"));
1103
+ if (byteLength(JSON.stringify({ events: [wire(item)] })) > MAX_BYTES) {
1104
+ safeDrop(this.options.onDrop, item, "oversized");
1105
+ return Promise.reject(/* @__PURE__ */ new Error("event exceeds 512 KB batch limit"));
1106
+ }
1107
+ return new Promise((resolve, reject) => {
1108
+ this.pending.push({
1109
+ event: item,
1110
+ resolve,
1111
+ reject
1112
+ });
1113
+ if (!this.writeTimer) this.writeTimer = setTimeout(() => {
1114
+ this.persistPending();
1115
+ }, COALESCE_MS);
1116
+ });
1117
+ }
1118
+ async persistPending() {
1119
+ if (this.writeTimer) clearTimeout(this.writeTimer);
1120
+ this.writeTimer = null;
1121
+ const batch = this.pending.splice(0);
1122
+ if (!batch.length) return;
1123
+ try {
1124
+ let evictedKeys = /* @__PURE__ */ new Set();
1125
+ await this.exclusive(async () => {
1126
+ const before = this.events;
1127
+ this.events = [...this.events, ...batch.map((p) => p.event)];
1128
+ const evicted = [];
1129
+ while (this.events.length > this.options.maxQueue) {
1130
+ const idx = this.events.findIndex((e) => e.telemetry);
1131
+ if (idx < 0) break;
1132
+ evicted.push(this.events[idx]);
1133
+ this.events.splice(idx, 1);
1134
+ }
1135
+ try {
1136
+ await this.save();
1137
+ } catch (err) {
1138
+ this.events = before;
1139
+ throw err;
1140
+ }
1141
+ evictedKeys = new Set(evicted.map((e) => e.idempotency_key));
1142
+ for (const e of evicted) safeDrop(this.options.onDrop, e, "telemetry_evicted");
1143
+ });
1144
+ for (const p of batch) if (evictedKeys.has(p.event.idempotency_key)) p.reject(/* @__PURE__ */ new Error("event evicted before durable enqueue"));
1145
+ else p.resolve();
1146
+ this.schedule();
1147
+ if (this.online !== false && this.events.length >= this.options.batchSize) this.flush();
1148
+ } catch (err) {
1149
+ for (const p of batch) p.reject(err);
1150
+ }
1151
+ }
1152
+ setOnline(value) {
1153
+ const wasOnline = this.online;
1154
+ this.online = value;
1155
+ if (!value) {
1156
+ this.cancelTimers();
1157
+ return;
1158
+ }
1159
+ this.schedule();
1160
+ if (wasOnline !== true) this.flush();
1161
+ }
1162
+ setBatchingEnabled(enabled) {
1163
+ this.batchingEnabled = enabled;
1164
+ if (this.online !== false) this.flush();
1165
+ }
1166
+ get isBatchingEnabled() {
1167
+ return this.batchingEnabled;
1168
+ }
1169
+ async pendingCount(userId) {
1170
+ await this.persistPending();
1171
+ return this.events.filter((e) => userId === void 0 || e.userId === userId).length;
1172
+ }
1173
+ async clear(userId) {
1174
+ this.clearingUsers.add(userId);
1175
+ try {
1176
+ await this.inFlight?.catch(() => void 0);
1177
+ await this.persistPending();
1178
+ await this.exclusive(async () => {
1179
+ const prior = this.events;
1180
+ const id = this.identity();
1181
+ this.events = this.events.filter((e) => e.userId !== userId && !(id.userId === userId && e.userId === null && e.anonymousId === id.anonymousId));
1182
+ try {
1183
+ await this.save();
1184
+ } catch (err) {
1185
+ this.events = prior;
1186
+ throw err;
1187
+ }
1188
+ });
1189
+ } finally {
1190
+ this.clearingUsers.delete(userId);
1191
+ this.schedule();
1192
+ }
1193
+ }
1194
+ async expireHeld() {
1195
+ const current = this.identity();
1196
+ await this.exclusive(async () => {
1197
+ const now = Date.now();
1198
+ const dropped = [];
1199
+ const next = [];
1200
+ for (const e of this.events) {
1201
+ if (e.userId && e.userId !== current.userId || !e.userId && e.anonymousId !== current.anonymousId) {
1202
+ e.heldAt ??= new Date(now).toISOString();
1203
+ if (now - Date.parse(e.heldAt) >= HELD_TTL_MS) {
1204
+ dropped.push(e);
1205
+ continue;
1206
+ }
1207
+ } else e.heldAt = void 0;
1208
+ next.push(e);
1209
+ }
1210
+ this.events = next;
1211
+ await this.save();
1212
+ for (const e of dropped) safeDrop(this.options.onDrop, e, "held_expired");
1213
+ });
1214
+ }
1215
+ cancelTimers() {
1216
+ if (this.flushTimer) clearTimeout(this.flushTimer);
1217
+ if (this.retryTimer) clearTimeout(this.retryTimer);
1218
+ this.flushTimer = this.retryTimer = null;
1219
+ }
1220
+ schedule() {
1221
+ if (this.flushTimer) clearTimeout(this.flushTimer);
1222
+ this.flushTimer = null;
1223
+ if (this.disposed || this.online !== true || this.failures > 0 || this.options.flushIntervalMs === 0) return;
1224
+ const id = this.identity();
1225
+ if (!id.sessionToken || !id.userId) return;
1226
+ const eligible = this.events.filter((e) => e.userId === id.userId || e.userId === null && e.anonymousId === id.anonymousId);
1227
+ if (!eligible.length) return;
1228
+ const oldest = Math.min(...eligible.map((e) => Date.parse(e.timestamp)));
1229
+ const wait = Math.max(0, oldest + this.options.flushIntervalMs - Date.now());
1230
+ this.flushTimer = setTimeout(() => {
1231
+ this.flush();
1232
+ }, wait);
1233
+ }
1234
+ retry() {
1235
+ this.failures += 1;
1236
+ if (this.retryTimer) clearTimeout(this.retryTimer);
1237
+ if (this.online !== true) return;
1238
+ const delay$1 = Math.min(6e4, 1e3 * 2 ** Math.min(this.failures - 1, 6));
1239
+ this.retryTimer = setTimeout(() => {
1240
+ this.flush();
1241
+ }, delay$1);
1242
+ }
1243
+ async flush() {
1244
+ if (this.inFlight) return this.inFlight;
1245
+ this.inFlight = this.runFlush().finally(() => {
1246
+ this.inFlight = null;
1247
+ });
1248
+ return this.inFlight;
1249
+ }
1250
+ async runFlush() {
1251
+ await this.persistPending();
1252
+ if (this.disposed || this.online === false) return {
1253
+ sent: 0,
1254
+ rejected: 0,
1255
+ remaining: this.events.length
1256
+ };
1257
+ await this.expireHeld();
1258
+ const id = this.identity();
1259
+ if (!id.sessionToken || !id.userId) return {
1260
+ sent: 0,
1261
+ rejected: 0,
1262
+ remaining: this.events.length
1263
+ };
1264
+ let sent = 0;
1265
+ let rejected = 0;
1266
+ for (;;) {
1267
+ if (this.disposed || this.online === false || this.clearingUsers.has(id.userId) || this.identity().userId !== id.userId) break;
1268
+ const eligible = this.events.filter((e) => e.userId === id.userId || e.userId === null && e.anonymousId === id.anonymousId);
1269
+ if (!eligible.length) break;
1270
+ const selected = [];
1271
+ for (const e of eligible) {
1272
+ if (selected.length >= this.options.batchSize) break;
1273
+ const candidate = [...selected, e];
1274
+ if (byteLength(JSON.stringify({ events: candidate.map(wire) })) > MAX_BYTES) break;
1275
+ selected.push(e);
1276
+ }
1277
+ if (!selected.length) break;
1278
+ await this.exclusive(async () => {
1279
+ for (const e of selected) if (e.userId === null) e.userId = id.userId;
1280
+ await this.save();
1281
+ });
1282
+ if (this.clearingUsers.has(id.userId) || this.identity().userId !== id.userId) break;
1283
+ try {
1284
+ const ack = this.batchingEnabled ? await this.postBatch({ events: selected.map(wire) }, id.sessionToken) : { data: {
1285
+ accepted: [],
1286
+ rejected: []
1287
+ } };
1288
+ if (!this.batchingEnabled) for (const e of selected) {
1289
+ await this.postSingle(wire(e), id.sessionToken);
1290
+ ack.data.accepted.push({
1291
+ idempotency_key: e.idempotency_key,
1292
+ status: "inserted"
1293
+ });
1294
+ }
1295
+ const accepted = new Set(ack.data.accepted.map((a) => a.idempotency_key));
1296
+ const drops = new Map(ack.data.rejected.map((r) => [r.idempotency_key, r.code]));
1297
+ const known = new Set(selected.map((e) => e.idempotency_key));
1298
+ if ([...accepted, ...drops.keys()].some((k) => !known.has(k))) throw new Error("event batch returned unknown key");
1299
+ if (accepted.size + drops.size !== selected.length) throw new Error("event batch omitted acknowledgments");
1300
+ await this.exclusive(async () => {
1301
+ const prior = this.events;
1302
+ this.events = this.events.filter((e) => !accepted.has(e.idempotency_key) && !drops.has(e.idempotency_key));
1303
+ try {
1304
+ await this.save();
1305
+ } catch (err) {
1306
+ this.events = prior;
1307
+ throw err;
1308
+ }
1309
+ });
1310
+ sent += accepted.size;
1311
+ rejected += drops.size;
1312
+ for (const e of selected) if (drops.has(e.idempotency_key)) safeDrop(this.options.onDrop, e, "server_rejected", drops.get(e.idempotency_key));
1313
+ this.failures = 0;
1314
+ } catch {
1315
+ this.retry();
1316
+ break;
1317
+ }
1318
+ }
1319
+ this.schedule();
1320
+ return {
1321
+ sent,
1322
+ rejected,
1323
+ remaining: this.events.length
1324
+ };
1325
+ }
1326
+ async dispose() {
1327
+ this.disposed = true;
1328
+ this.cancelTimers();
1329
+ await this.persistPending();
1330
+ await this.inFlight?.catch(() => void 0);
1331
+ await this.serialized;
1332
+ }
1333
+ };
1334
+
668
1335
  //#endregion
669
1336
  //#region src/push-rotation.ts
670
1337
  /**
@@ -866,7 +1533,7 @@ var CollectionSubscription = class {
866
1533
  if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
867
1534
  const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
868
1535
  return openStream({
869
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
1536
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
870
1537
  authHeaders: this.ctx.authHeaders,
871
1538
  onChange: cb,
872
1539
  options,
@@ -890,7 +1557,7 @@ var ConversationSubscription = class {
890
1557
  subscribe(cb, options = {}) {
891
1558
  if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn* first.");
892
1559
  return openStream({
893
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
1560
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
894
1561
  authHeaders: this.ctx.authHeaders,
895
1562
  onChange: cb,
896
1563
  options,
@@ -1150,13 +1817,14 @@ function delay(ms) {
1150
1817
  //#endregion
1151
1818
  //#region src/index.ts
1152
1819
  /** SDK semver — burned in at build time. Bump alongside package.json. */
1153
- const SDK_VERSION = "4.0.6";
1820
+ const SDK_VERSION = "4.0.8";
1154
1821
  /**
1155
1822
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
1156
1823
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
1157
1824
  * `functionsBaseUrl` override is configured.
1158
1825
  */
1159
1826
  const DEFAULT_FUNCTIONS_HOST = "fn.amba.host";
1827
+ const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1160
1828
  let state = null;
1161
1829
  /**
1162
1830
  * Monotonic token identifying the current offline activation. Bumped on every
@@ -1210,7 +1878,7 @@ function normalizeAuthProviderNames(value) {
1210
1878
  try {
1211
1879
  normalized = JSON.parse(trimmed);
1212
1880
  } catch {
1213
- return [normalized];
1881
+ return [trimmed];
1214
1882
  }
1215
1883
  }
1216
1884
  if (!Array.isArray(normalized)) {
@@ -1272,6 +1940,7 @@ async function persistAuthResult(state_, result) {
1272
1940
  async function postAuth(state_, path, body) {
1273
1941
  const result = normalizeAuthResult((await state_.http.post(path, body)).data);
1274
1942
  await persistAuthResult(state_, result);
1943
+ state_.eventQueue?.flush();
1275
1944
  await notifyFromAuthResult(result);
1276
1945
  return result;
1277
1946
  }
@@ -1389,24 +2058,53 @@ const P = {
1389
2058
  deepLinks: "/v1/client/deep-links",
1390
2059
  onboarding: "/v1/client/onboarding"
1391
2060
  };
2061
+ /**
2062
+ * Encode one caller-supplied id, key or name as a single URL path segment.
2063
+ * `encodeURIComponent` escapes `/`, `?` and `#`; `.` and `..` are refused
2064
+ * because URL resolution treats them as steps to another route, and the
2065
+ * empty string is refused because it collapses onto the parent route.
2066
+ */
1392
2067
  function encodePathSegment(s) {
1393
- return encodeURIComponent(s);
2068
+ return pathSegment(s);
2069
+ }
2070
+ function nonceField(options) {
2071
+ const nonce = options?.nonce;
2072
+ if (nonce === void 0) return {};
2073
+ if (typeof nonce !== "string" || nonce.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "nonce must be a non-empty string");
2074
+ return { nonce };
1394
2075
  }
1395
2076
  /**
1396
- * Resolve the full invocation URL for a deployed function. With
1397
- * `functionsBaseUrl` configured we POST to `<base>/<name>` verbatim;
1398
- * otherwise we resolve the server-side project id once (via
1399
- * `diagnostics.ping`, cached) and target
1400
- * `https://<projectId>.fn.amba.host/<name>`.
2077
+ * Resolve the full invocation URL for a deployed function. Resolution checks
2078
+ * `functionsBaseUrl` first, then uses a UUID-shaped configured project id
2079
+ * directly, and finally falls back to a deduped `diagnostics.ping` for the
2080
+ * authoritative server-side project id. The resolved default-host id is
2081
+ * cached for subsequent invokes.
1401
2082
  */
1402
2083
  async function resolveFunctionUrl(s, name) {
1403
- if (s.functionsBaseUrl) return `${s.functionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
1404
- if (!s.resolvedFunctionsProjectId) {
1405
- const ping = await Amba.diagnostics.ping();
1406
- 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);
1407
- s.resolvedFunctionsProjectId = ping.server_project_id;
2084
+ if (s.functionsBaseUrl) return `${s.functionsBaseUrl.replace(/\/$/, "")}/${pathSegment(name)}`;
2085
+ return `https://${await resolveFunctionsProjectId(s)}.${DEFAULT_FUNCTIONS_HOST}/${pathSegment(name)}`;
2086
+ }
2087
+ async function resolveFunctionsProjectId(s) {
2088
+ if (s.resolvedFunctionsProjectId) return s.resolvedFunctionsProjectId;
2089
+ if (PROJECT_ID_PATTERN.test(s.projectId)) {
2090
+ s.resolvedFunctionsProjectId = s.projectId.toLowerCase();
2091
+ return s.resolvedFunctionsProjectId;
1408
2092
  }
1409
- return `https://${s.resolvedFunctionsProjectId}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
2093
+ if (!s.functionsProjectIdInflight) {
2094
+ const resolution = (async () => {
2095
+ const ping = await Amba.diagnostics.ping();
2096
+ 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);
2097
+ s.resolvedFunctionsProjectId = ping.server_project_id;
2098
+ return ping.server_project_id;
2099
+ })();
2100
+ s.functionsProjectIdInflight = resolution;
2101
+ resolution.then(() => {
2102
+ if (s.functionsProjectIdInflight === resolution) s.functionsProjectIdInflight = null;
2103
+ }, () => {
2104
+ if (s.functionsProjectIdInflight === resolution) s.functionsProjectIdInflight = null;
2105
+ });
2106
+ }
2107
+ return s.functionsProjectIdInflight;
1410
2108
  }
1411
2109
  /**
1412
2110
  * Bridge the AsyncStorage-backed persistence to the {@link KvStore} the
@@ -1461,6 +2159,10 @@ function getOfflineQueue(s) {
1461
2159
  store: offlineStore(s),
1462
2160
  storageKey: `offline_outbox:${s.projectId}`,
1463
2161
  executor: (op) => replayOutboxOp(s, op),
2162
+ currentOwner: () => ({
2163
+ appUserId: s.identity.getSessionToken() || s.identity.getRefreshToken() ? s.identity.getAppUserId() : null,
2164
+ anonymousId: s.identity.getAnonymousId()
2165
+ }),
1464
2166
  onDropped: (op, err) => {
1465
2167
  if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1466
2168
  }
@@ -1483,9 +2185,24 @@ const Amba = {
1483
2185
  configure: async (config) => {
1484
2186
  if (!config.projectId || config.projectId.trim() === "") throw new Error("Amba.configure: projectId is required");
1485
2187
  if (!config.clientKey || config.clientKey.trim() === "") throw new Error("Amba.configure: clientKey is required");
1486
- const persistence = new AsyncStoragePersistence(config.storagePrefix ?? "amba");
2188
+ const batchSize = config.events?.batchSize ?? 100;
2189
+ const flushIntervalMs = config.events?.flushIntervalMs ?? 6e4;
2190
+ const maxQueue = config.events?.maxQueue ?? 1e4;
2191
+ if (config.events?.batching) {
2192
+ if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 500) throw new Error("Amba.configure: events.batchSize must be 1–500");
2193
+ if (!Number.isInteger(flushIntervalMs) || flushIntervalMs < 0) throw new Error("Amba.configure: events.flushIntervalMs must be a non-negative integer");
2194
+ if (!Number.isInteger(maxQueue) || maxQueue < 1) throw new Error("Amba.configure: events.maxQueue must be positive");
2195
+ }
2196
+ const tokenStorage = config.tokenStorage ?? "secure";
2197
+ if (!TOKEN_STORAGE_MODES.includes(tokenStorage)) throw new Error("Amba.configure: tokenStorage must be \"secure\", \"memory\" or \"asyncStorage\"");
2198
+ const secureStorage = config.secureStorage;
2199
+ if (secureStorage !== void 0 && (typeof secureStorage?.getItem !== "function" || typeof secureStorage?.setItem !== "function" || typeof secureStorage?.removeItem !== "function")) throw new Error("Amba.configure: secureStorage must provide getItem, setItem and removeItem");
2200
+ const storagePrefix = config.storagePrefix ?? "amba";
2201
+ const persistence = new AsyncStoragePersistence(storagePrefix);
2202
+ if (config.events?.batching) await persistence.assertDurable();
1487
2203
  await persistence.hydrate();
1488
- const identity = await IdentityStore.create(persistence);
2204
+ const vault = await resolveTokenVault(tokenStorage, storagePrefix, secureStorage);
2205
+ const identity = await IdentityStore.create(persistence, vault);
1489
2206
  const baseUrl = (config.apiUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
1490
2207
  const sdkVersionHeader = `amba-sdk/${SDK_VERSION} ${`amba-react-native/${SDK_VERSION}`}`;
1491
2208
  const http = new AmbaHttpClient({
@@ -1502,7 +2219,9 @@ const Amba = {
1502
2219
  const prior = state;
1503
2220
  _offlineEnableToken += 1;
1504
2221
  if (prior?.offlineQueue) await prior.offlineQueue.dispose();
1505
- state = {
2222
+ prior?.detachEventAppState?.();
2223
+ if (prior?.eventQueue) await prior.eventQueue.dispose();
2224
+ const nextState = {
1506
2225
  http,
1507
2226
  identity,
1508
2227
  debug: config.debug ?? false,
@@ -1511,12 +2230,39 @@ const Amba = {
1511
2230
  baseUrl,
1512
2231
  functionsBaseUrl: config.functionsBaseUrl ?? null,
1513
2232
  resolvedFunctionsProjectId: null,
2233
+ functionsProjectIdInflight: null,
1514
2234
  persistence,
1515
2235
  offlineEnabled: false,
1516
- offlineQueue: null
2236
+ offlineQueue: null,
2237
+ eventQueue: null
1517
2238
  };
2239
+ state = nextState;
2240
+ if (config.events?.batching) {
2241
+ const queue = new EventBatchQueue(offlineStore(nextState), `event_outbox:${nextState.projectId}`, {
2242
+ batchSize,
2243
+ flushIntervalMs,
2244
+ maxQueue,
2245
+ onDrop: config.events.onDrop
2246
+ }, () => ({
2247
+ userId: nextState.identity.getAppUserId(),
2248
+ anonymousId: nextState.identity.getAnonymousId(),
2249
+ sessionToken: nextState.identity.getSessionToken()
2250
+ }), (body, token) => nextState.http.postWithSession("/v1/client/events/batch", body, token), (body, token) => nextState.http.postWithSession(P.events, body, token));
2251
+ nextState.eventQueue = queue;
2252
+ await queue.start();
2253
+ try {
2254
+ const { AppState } = require("react-native");
2255
+ const sub = AppState.addEventListener("change", (status) => {
2256
+ if (status === "background") queue.flush();
2257
+ });
2258
+ nextState.detachEventAppState = () => sub.remove();
2259
+ } catch {}
2260
+ }
2261
+ if (!nextState.functionsBaseUrl && !PROJECT_ID_PATTERN.test(nextState.projectId)) resolveFunctionsProjectId(nextState).catch(() => {});
1518
2262
  },
1519
2263
  _reset: () => {
2264
+ state?.detachEventAppState?.();
2265
+ state?.eventQueue?.dispose();
1520
2266
  state = null;
1521
2267
  authSubscribers.clear();
1522
2268
  },
@@ -1545,32 +2291,52 @@ const Amba = {
1545
2291
  authHeaders: realtimeAuthHeaders
1546
2292
  });
1547
2293
  },
1548
- events: { track: async (event, properties, options) => {
1549
- const s = getState();
1550
- const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
1551
- const body = {
1552
- event,
1553
- properties: properties ?? void 0,
1554
- anonymous_id: s.identity.getAnonymousId(),
1555
- timestamp: occurredAt
1556
- };
1557
- if (options?.telemetry) body["telemetry"] = true;
1558
- try {
1559
- await s.http.post(P.events, body);
1560
- } catch (err) {
1561
- if (s.offlineEnabled && isOfflineError(err)) {
1562
- await getOfflineQueue(s).enqueue({
1563
- kind: "track",
1564
- event,
1565
- properties,
1566
- telemetry: options?.telemetry,
1567
- occurredAt
1568
- });
1569
- return;
2294
+ events: {
2295
+ track: async (event, properties, options) => {
2296
+ const s = getState();
2297
+ if (s.eventQueue && !options?.immediate) return s.eventQueue.enqueue(event, properties, options?.telemetry === true);
2298
+ const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
2299
+ const body = {
2300
+ event,
2301
+ properties: properties ?? void 0,
2302
+ anonymous_id: s.identity.getAnonymousId(),
2303
+ timestamp: occurredAt
2304
+ };
2305
+ if (options?.telemetry) body["telemetry"] = true;
2306
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2307
+ try {
2308
+ await s.http.post(P.events, body);
2309
+ } catch (err) {
2310
+ if (s.offlineEnabled && isOfflineError(err)) {
2311
+ await getOfflineQueue(s).enqueue({
2312
+ kind: "track",
2313
+ event,
2314
+ properties,
2315
+ telemetry: options?.telemetry,
2316
+ occurredAt
2317
+ }, outboxTicket);
2318
+ return;
2319
+ }
2320
+ throw err;
1570
2321
  }
1571
- throw err;
2322
+ },
2323
+ flush: async () => getState().eventQueue?.flush() ?? {
2324
+ sent: 0,
2325
+ rejected: 0,
2326
+ remaining: 0
2327
+ },
2328
+ pendingCount: async (userId) => getState().eventQueue?.pendingCount(userId) ?? 0,
2329
+ clear: async ({ userId }) => {
2330
+ if (!userId) throw new Error("Amba.events.clear: userId is required");
2331
+ await getState().eventQueue?.clear(userId);
2332
+ },
2333
+ setOnline: (online) => {
2334
+ getState().eventQueue?.setOnline(online);
2335
+ },
2336
+ setBatchingEnabled: (enabled) => {
2337
+ getState().eventQueue?.setBatchingEnabled(enabled);
1572
2338
  }
1573
- } },
2339
+ },
1574
2340
  auth: {
1575
2341
  signInAnonymously: async () => {
1576
2342
  const s = getState();
@@ -1592,18 +2358,20 @@ const Amba = {
1592
2358
  password
1593
2359
  });
1594
2360
  },
1595
- signInWithApple: async (identityToken) => {
2361
+ signInWithApple: async (identityToken, options) => {
1596
2362
  if (identityToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "identity_token must not be empty");
1597
2363
  return postAuth(getState(), P.authSocial, {
1598
2364
  provider: "apple",
1599
- id_token: identityToken
2365
+ id_token: identityToken,
2366
+ ...nonceField(options)
1600
2367
  });
1601
2368
  },
1602
- signInWithGoogle: async (idToken) => {
2369
+ signInWithGoogle: async (idToken, options) => {
1603
2370
  if (idToken.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "id_token must not be empty");
1604
2371
  return postAuth(getState(), P.authSocial, {
1605
2372
  provider: "google",
1606
- id_token: idToken
2373
+ id_token: idToken,
2374
+ ...nonceField(options)
1607
2375
  });
1608
2376
  },
1609
2377
  requestEmailOtp: async (email) => {
@@ -1638,7 +2406,7 @@ const Amba = {
1638
2406
  if (token.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "token must not be empty");
1639
2407
  return postAuth(getState(), P.authMagicLinkVerify, { token });
1640
2408
  },
1641
- linkAccount: async (provider, credential) => {
2409
+ linkAccount: async (provider, credential, options) => {
1642
2410
  const providerTrimmed = provider?.trim() ?? "";
1643
2411
  if (providerTrimmed === "") throw new AmbaApiError("VALIDATION_ERROR", "provider must not be empty");
1644
2412
  if (typeof credential !== "string" || credential.trim() === "") throw new AmbaApiError("VALIDATION_ERROR", "credential must not be empty");
@@ -1648,7 +2416,8 @@ const Amba = {
1648
2416
  return postAuth(s, P.authLink, {
1649
2417
  provider: providerTrimmed,
1650
2418
  token: credential,
1651
- session_token: sessionToken
2419
+ session_token: sessionToken,
2420
+ ...nonceField(options)
1652
2421
  });
1653
2422
  },
1654
2423
  linkSmsOtp: async (phone, code) => {
@@ -1707,6 +2476,12 @@ const Amba = {
1707
2476
  if (typeof console !== "undefined") console.warn("[amba] signOut server call failed", err);
1708
2477
  }
1709
2478
  await s.identity.clearSession(rotateAnonymousId);
2479
+ try {
2480
+ await getOfflineQueue(s).purge();
2481
+ } catch (err) {
2482
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
2483
+ }
2484
+ s.eventQueue?.flush();
1710
2485
  notifyAuthSubscribers(null);
1711
2486
  },
1712
2487
  refresh: async () => {
@@ -1784,7 +2559,16 @@ const Amba = {
1784
2559
  isEnabled: () => getState().offlineEnabled,
1785
2560
  pendingCount: async () => getOfflineQueue(getState()).pendingCount(),
1786
2561
  pending: async () => getOfflineQueue(getState()).pending(),
1787
- flush: async () => getOfflineQueue(getState()).flush(),
2562
+ flush: async () => {
2563
+ const s = getState();
2564
+ const result = s.offlineEnabled ? await getOfflineQueue(s).flush() : {
2565
+ flushed: 0,
2566
+ dropped: 0,
2567
+ remaining: s.offlineQueue ? await s.offlineQueue.pendingCount() : 0
2568
+ };
2569
+ await s.eventQueue?.flush();
2570
+ return result;
2571
+ },
1788
2572
  clear: async () => getOfflineQueue(getState()).clear()
1789
2573
  },
1790
2574
  collections: {
@@ -1812,6 +2596,7 @@ const Amba = {
1812
2596
  const s = getState();
1813
2597
  if (s.offlineEnabled) {
1814
2598
  const idempotencyKey = (typeof row["idempotency_key"] === "string" ? row["idempotency_key"] : void 0) ?? newIdempotencyKey();
2599
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
1815
2600
  try {
1816
2601
  return (await s.http.post(`${P.collections}/${encodePathSegment(name)}`, {
1817
2602
  ...row,
@@ -1824,7 +2609,7 @@ const Amba = {
1824
2609
  collection: name,
1825
2610
  payload: row,
1826
2611
  idempotencyKey
1827
- });
2612
+ }, outboxTicket);
1828
2613
  return {
1829
2614
  ...row,
1830
2615
  _amba_pending: true
@@ -1837,40 +2622,46 @@ const Amba = {
1837
2622
  },
1838
2623
  update: async (name, id, set) => {
1839
2624
  const s = getState();
1840
- if (s.offlineEnabled) try {
1841
- return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
1842
- } catch (err) {
1843
- if (isOfflineError(err)) {
1844
- await getOfflineQueue(s).enqueue({
1845
- kind: "collection.update",
1846
- collection: name,
1847
- id,
1848
- payload: set
1849
- });
1850
- return {
1851
- id,
1852
- ...set,
1853
- _amba_pending: true
1854
- };
2625
+ if (s.offlineEnabled) {
2626
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2627
+ try {
2628
+ return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
2629
+ } catch (err) {
2630
+ if (isOfflineError(err)) {
2631
+ await getOfflineQueue(s).enqueue({
2632
+ kind: "collection.update",
2633
+ collection: name,
2634
+ id,
2635
+ payload: set
2636
+ }, outboxTicket);
2637
+ return {
2638
+ id,
2639
+ ...set,
2640
+ _amba_pending: true
2641
+ };
2642
+ }
2643
+ throw err;
1855
2644
  }
1856
- throw err;
1857
2645
  }
1858
2646
  return (await s.http.patch(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`, { set })).data;
1859
2647
  },
1860
2648
  delete: async (name, id) => {
1861
2649
  const s = getState();
1862
- if (s.offlineEnabled) try {
1863
- return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
1864
- } catch (err) {
1865
- if (isOfflineError(err)) {
1866
- await getOfflineQueue(s).enqueue({
1867
- kind: "collection.delete",
1868
- collection: name,
1869
- id
1870
- });
1871
- return { data: { deleted: true } };
2650
+ if (s.offlineEnabled) {
2651
+ const outboxTicket = s.offlineEnabled ? getOfflineQueue(s).ticket() : void 0;
2652
+ try {
2653
+ return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
2654
+ } catch (err) {
2655
+ if (isOfflineError(err)) {
2656
+ await getOfflineQueue(s).enqueue({
2657
+ kind: "collection.delete",
2658
+ collection: name,
2659
+ id
2660
+ }, outboxTicket);
2661
+ return { data: { deleted: true } };
2662
+ }
2663
+ throw err;
1872
2664
  }
1873
- throw err;
1874
2665
  }
1875
2666
  return await s.http.delete(`${P.collections}/${encodePathSegment(name)}/${encodePathSegment(id)}`) ?? { data: { deleted: true } };
1876
2667
  },
@@ -2419,10 +3210,7 @@ const Amba = {
2419
3210
  complete: async () => {
2420
3211
  return (await getState().http.post(`${P.onboarding}/complete`, {})).data;
2421
3212
  }
2422
- },
2423
- dev: { resetSandbox: async () => {
2424
- return (await getState().http.post("/v1/client/dev/reset-sandbox", {})).data;
2425
- } }
3213
+ }
2426
3214
  };
2427
3215
 
2428
3216
  //#endregion