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