@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/README.md +6 -3
- package/dist/index.cjs +923 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +247 -133
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +247 -133
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +930 -135
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
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
|
|
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
|
-
|
|
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`
|
|
267
|
-
* Expired session tokens are dropped at hydration (the refresh
|
|
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
|
-
|
|
334
|
-
|
|
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:
|
|
338
|
-
refresh_token:
|
|
339
|
-
expires_at:
|
|
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(
|
|
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
|
-
/**
|
|
521
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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/${
|
|
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/${
|
|
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.
|
|
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 [
|
|
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
|
|
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.
|
|
1397
|
-
* `functionsBaseUrl`
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
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(/\/$/, "")}/${
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: {
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
await
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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)
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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)
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
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
|