@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.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
|
|
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
|
-
|
|
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`
|
|
266
|
-
* Expired session tokens are dropped at hydration (the refresh
|
|
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
|
-
|
|
333
|
-
|
|
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:
|
|
337
|
-
refresh_token:
|
|
338
|
-
expires_at:
|
|
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(
|
|
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
|
-
/**
|
|
520
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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/${
|
|
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/${
|
|
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.
|
|
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 [
|
|
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
|
|
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.
|
|
1396
|
-
* `functionsBaseUrl`
|
|
1397
|
-
*
|
|
1398
|
-
*
|
|
1399
|
-
*
|
|
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(/\/$/, "")}/${
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: {
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
await
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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)
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
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)
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|