@layers/amba-web 4.0.10 → 4.0.11

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.
@@ -38,6 +38,26 @@ interface AmbaConfig {
38
38
  debug?: boolean;
39
39
  /** Override the localStorage key prefix. Defaults to `"amba"`. */
40
40
  storagePrefix?: string;
41
+ /**
42
+ * Where the SDK keeps the signed-in session between page loads.
43
+ *
44
+ * - `"local"` (default): the session token, refresh token, user id and
45
+ * anonymous id are saved in `localStorage`, so a reload or a new tab
46
+ * stays signed in. Any script running on your origin can read
47
+ * `localStorage`, so a cross-site scripting bug in the page can copy the
48
+ * refresh token (valid for 90 days) and keep using the session after the
49
+ * tab closes.
50
+ * - `"memory"`: none of that identity is written to storage; it lives in
51
+ * this page's memory and ends with it. A script injected later finds no
52
+ * stored token to copy. Every reload or new tab starts signed out with a
53
+ * new anonymous identity, so users sign in again each visit. Configuring
54
+ * with `"memory"` also removes an identity an earlier `"local"`
55
+ * configuration saved for this project.
56
+ *
57
+ * Queued offline writes (`Amba.offline`) stay in `localStorage` in both
58
+ * modes.
59
+ */
60
+ sessionPersistence?: SessionPersistence;
41
61
  /**
42
62
  * Advanced: explicit URL or RequestInfo for the @layers/amba-core WASM file.
43
63
  * Browser bundlers should not need this; the browser entry supplies a
@@ -50,6 +70,8 @@ interface AmbaConfig {
50
70
  */
51
71
  wasmModule?: Response | BufferSource | WebAssembly.Module;
52
72
  }
73
+ /** `AmbaConfig.sessionPersistence` values. */
74
+ type SessionPersistence = "local" | "memory";
53
75
  type SocialProvider = "apple" | "google";
54
76
  type PushPlatform = "apns" | "fcm" | "web";
55
77
  interface User {
@@ -1244,6 +1266,10 @@ interface AiStreamOptions {
1244
1266
  * - On reconnect (`online` event) the outbox is flushed in FIFO order;
1245
1267
  * each op is removed only after it succeeds, so a mid-flush failure
1246
1268
  * leaves the remainder intact for the next attempt.
1269
+ * - Each op records its owner (the user id and anonymous id live when it
1270
+ * was queued). A flush replays only the current owner's ops and holds
1271
+ * the rest until that owner signs back in; `pending()` lists only the
1272
+ * current owner's ops. The SDKs clear the whole outbox on sign-out.
1247
1273
  */
1248
1274
  /**
1249
1275
  * Minimal durable key/value contract the outbox persists through. Both the
@@ -1255,6 +1281,14 @@ interface KvStore {
1255
1281
  set(key: string, value: string): Promise<void> | void;
1256
1282
  delete(key: string): Promise<void> | void;
1257
1283
  }
1284
+ /**
1285
+ * The identity a queued op belongs to: the signed-in user id (null before any
1286
+ * session) and the install's anonymous id, both captured at enqueue.
1287
+ */
1288
+ interface OutboxOwner {
1289
+ appUserId: string | null;
1290
+ anonymousId: string | null;
1291
+ }
1258
1292
  /** The kinds of mutation the outbox can durably replay. */
1259
1293
  type OutboxOpKind = "collection.insert" | "collection.update" | "collection.delete" | "track";
1260
1294
  /**
@@ -1288,6 +1322,11 @@ interface OutboxOp {
1288
1322
  * session user at replay time — the events route has no body-id channel.)
1289
1323
  */
1290
1324
  occurredAt?: string;
1325
+ /**
1326
+ * Who queued the op. Stamped at enqueue when the queue has a `currentOwner`
1327
+ * source; a flush replays the op only while the same owner is signed in.
1328
+ */
1329
+ owner?: OutboxOwner;
1291
1330
  enqueuedAt: string;
1292
1331
  }
1293
1332
  /** Result of a {@link OfflineQueue.flush}. */
@@ -1659,8 +1698,12 @@ declare const Amba: {
1659
1698
  * password)`. Requires an active session — throws `UNAUTHORIZED`
1660
1699
  * otherwise.
1661
1700
  *
1662
- * @returns the updated user record (with `email` set and
1663
- * `email_verified = true`).
1701
+ * The address stays unverified (`email_verified = false`) until the
1702
+ * inbox is proven, unless this user had already verified the same
1703
+ * address. `linkEmailOtp` with the same address from this session
1704
+ * verifies it and keeps the password.
1705
+ *
1706
+ * @returns the updated user record (with `email` set).
1664
1707
  */
1665
1708
  linkEmailPassword: (email: string, password: string) => Promise<User>;
1666
1709
  /**
@@ -2511,7 +2554,7 @@ declare function getCoreWasm(): AmbaCoreWasm;
2511
2554
  * `verify-sdk-version-coherence` GH action gates every publish on the
2512
2555
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
2513
2556
  */
2514
- declare const SDK_VERSION = "4.0.10";
2557
+ declare const SDK_VERSION = "4.0.11";
2515
2558
  //#endregion
2516
- export { Achievement, AchievementProgress, AiContentBlock, AiImageInput, AiMessage, AiMessageRequest, AiMessageResponse, type AiStreamDelta, type AiStreamDone, type AiStreamEvent, type AiStreamOptions, AiUsage, Amba, AmbaApiError, type AmbaApiErrorCode, AmbaConfig, AmbaGroupScopedClient, AndFilter, AppSession, AuthResult, CatalogItem, Challenge, ChallengeProgress, CollectionSubscription, ConditionFilter, ConfigBundle, ConsumeRequest, ContentItem, Conversation, ConversationParticipant, ConversationSubscription, ConversationType, CreateConversationRequest, CurrencyBalance, CurrencyTransaction, DeepLink, DeepLinkCreate, DeleteManyResult, DiagnosticsEnvironment, EntitlementPeriodType, EntitlementStore, EventExplainResult, ExplainMatchedRule, FeedItem, FeedResponse, Filter, FilterValue, FindOptions, FindResponse, FlagAssignment, type FlushResult, Friendship, FriendshipState, type GamificationChange, type GamificationKind, GamificationSubscription, Group, GroupCreate, GroupMember, GroupUpdate, IncludeClause, InventoryItem, InvokeOptions, type KvStore, Leaderboard, LeaderboardEntry, LeagueCohort, LeagueCohortMember, LeagueCohortView, LeagueMembership, LeagueTier, LinkedIdentifier, ListMessagesOptions, LocalNotificationHandle, LocalNotificationTrigger, type LocalNotificationsNamespace, MarkReadResult, MediaAsset, MediaCatalog, MediaCatalogItem, MediaListResponse, Message, type MessageChange, MessageType, NearestOptions, NearestResponse, NotFilter, Offering, OfferingPackage, OfferingProduct, OfferingsResult, OnboardingStatus, OrFilter, OrderBy, type OutboxOp, type OutboxOpKind, PingResult, PresignData, PullChangesCursor, PullChangesResult, PurchaseOption, PurchaseRequest, PurchaseResult, PushChangesResult, PushPlatform, PushToken, type RealtimeChange, ReferralClaim, ReferralCode, Report, ReportRequest, RestoreResult, Review, ReviewCreate, ReviewUpdate, Role, type RowChangeOp, SDK_VERSION, ScheduleLocalNotificationInput, ScheduledLocalNotification, SendMessageRequest, Session, SocialProvider, Store, Streak, type SubscribeOptions, SyncChange, SyncConflict, TransactionOp, TransactionOpResult, type Unsubscribe, UpsertOptions, User, UserEntitlement, XpBalance, XpTransaction, getCoreWasm, withOptimistic };
2559
+ export { Achievement, AchievementProgress, AiContentBlock, AiImageInput, AiMessage, AiMessageRequest, AiMessageResponse, type AiStreamDelta, type AiStreamDone, type AiStreamEvent, type AiStreamOptions, AiUsage, Amba, AmbaApiError, type AmbaApiErrorCode, AmbaConfig, AmbaGroupScopedClient, AndFilter, AppSession, AuthResult, CatalogItem, Challenge, ChallengeProgress, CollectionSubscription, ConditionFilter, ConfigBundle, ConsumeRequest, ContentItem, Conversation, ConversationParticipant, ConversationSubscription, ConversationType, CreateConversationRequest, CurrencyBalance, CurrencyTransaction, DeepLink, DeepLinkCreate, DeleteManyResult, DiagnosticsEnvironment, EntitlementPeriodType, EntitlementStore, EventExplainResult, ExplainMatchedRule, FeedItem, FeedResponse, Filter, FilterValue, FindOptions, FindResponse, FlagAssignment, type FlushResult, Friendship, FriendshipState, type GamificationChange, type GamificationKind, GamificationSubscription, Group, GroupCreate, GroupMember, GroupUpdate, IncludeClause, InventoryItem, InvokeOptions, type KvStore, Leaderboard, LeaderboardEntry, LeagueCohort, LeagueCohortMember, LeagueCohortView, LeagueMembership, LeagueTier, LinkedIdentifier, ListMessagesOptions, LocalNotificationHandle, LocalNotificationTrigger, type LocalNotificationsNamespace, MarkReadResult, MediaAsset, MediaCatalog, MediaCatalogItem, MediaListResponse, Message, type MessageChange, MessageType, NearestOptions, NearestResponse, NotFilter, Offering, OfferingPackage, OfferingProduct, OfferingsResult, OnboardingStatus, OrFilter, OrderBy, type OutboxOp, type OutboxOpKind, PingResult, PresignData, PullChangesCursor, PullChangesResult, PurchaseOption, PurchaseRequest, PurchaseResult, PushChangesResult, PushPlatform, PushToken, type RealtimeChange, ReferralClaim, ReferralCode, Report, ReportRequest, RestoreResult, Review, ReviewCreate, ReviewUpdate, Role, type RowChangeOp, SDK_VERSION, ScheduleLocalNotificationInput, ScheduledLocalNotification, SendMessageRequest, Session, SessionPersistence, SocialProvider, Store, Streak, type SubscribeOptions, SyncChange, SyncConflict, TransactionOp, TransactionOpResult, type Unsubscribe, UpsertOptions, User, UserEntitlement, XpBalance, XpTransaction, getCoreWasm, withOptimistic };
2517
2560
  //# sourceMappingURL=index.d.ts.map
package/dist-rn/index.js CHANGED
@@ -192,6 +192,20 @@ function toAmbaApiError(err) {
192
192
  return new AmbaApiError("UNKNOWN_ERROR", String(err));
193
193
  }
194
194
 
195
+ //#endregion
196
+ //#region src/path-segment.ts
197
+ /**
198
+ * Encode one caller-supplied id, key, slug or name as a single URL path
199
+ * segment. `encodeURIComponent` escapes `/`, `?` and `#`, so the value stays
200
+ * inside its segment. `.` and `..` are refused because URL resolution treats
201
+ * them as steps to another route, and the empty string is refused because it
202
+ * collapses onto the parent route.
203
+ */
204
+ function pathSegment(value) {
205
+ if (value === "" || value === "." || value === "..") throw new AmbaApiError("VALIDATION_ERROR", `path segment must not be ${value === "" ? "empty" : `'${value}'`}`);
206
+ return encodeURIComponent(value);
207
+ }
208
+
195
209
  //#endregion
196
210
  //#region src/realtime.ts
197
211
  const INITIAL_BACKOFF_MS = 500;
@@ -231,7 +245,7 @@ var CollectionSubscription = class {
231
245
  if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
232
246
  const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
233
247
  return openStream({
234
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
248
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
235
249
  authHeaders: this.ctx.authHeaders,
236
250
  onChange: cb,
237
251
  options
@@ -352,7 +366,7 @@ var ConversationSubscription = class {
352
366
  subscribe(cb, options = {}) {
353
367
  if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
354
368
  return openStream({
355
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
369
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
356
370
  authHeaders: this.ctx.authHeaders,
357
371
  onChange: cb,
358
372
  options
@@ -675,6 +689,19 @@ async function* invokeAiStream(args) {
675
689
  //#endregion
676
690
  //#region src/resilience.ts
677
691
  /**
692
+ * Whether `op` belongs to `current`. An op queued under a user id matches only
693
+ * that user id. An op queued before any session matches the same anonymous id,
694
+ * so a guest's offline write lands once that guest's session starts. An op
695
+ * with no recorded owner (queued by an SDK version before owners existed)
696
+ * matches nobody.
697
+ */
698
+ function outboxOwnerMatches(op, current) {
699
+ const owner = op.owner;
700
+ if (!owner) return false;
701
+ if (owner.appUserId) return owner.appUserId === current.appUserId;
702
+ return owner.anonymousId !== null && owner.anonymousId === current.anonymousId;
703
+ }
704
+ /**
678
705
  * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
679
706
  * exists; under Node / SSR it doesn't, so we treat "unknown" as online
680
707
  * (the executor's own error handling then decides).
@@ -758,8 +785,18 @@ var OfflineQueue = class {
758
785
  * SDK surface the loss (debug log) instead of silently discarding.
759
786
  */
760
787
  onDropped;
788
+ /**
789
+ * The identity that is signed in right now. When set, enqueue stamps it on
790
+ * each op and flush/pending only touch ops it owns.
791
+ */
792
+ currentOwner;
761
793
  flushing = null;
762
794
  /**
795
+ * Bumped by `purge()`. A replay that started before a purge sees the change
796
+ * and does not re-queue its op.
797
+ */
798
+ purgeGeneration = 0;
799
+ /**
763
800
  * Serializes every read-modify-write of the persisted outbox. Each mutation
764
801
  * chains onto the previous one's promise, so concurrent `enqueue`s — and an
765
802
  * `enqueue` racing the per-op removal inside `flush` — can never read a stale
@@ -782,6 +819,16 @@ var OfflineQueue = class {
782
819
  this.executor = opts.executor;
783
820
  this.storageKey = opts.storageKey ?? "offline_outbox";
784
821
  this.onDropped = opts.onDropped;
822
+ this.currentOwner = opts.currentOwner;
823
+ }
824
+ /**
825
+ * The ops the current owner may see and replay. Without an owner source
826
+ * every op qualifies.
827
+ */
828
+ visible(ops) {
829
+ if (!this.currentOwner) return ops;
830
+ const current = this.currentOwner();
831
+ return ops.filter((op) => outboxOwnerMatches(op, current));
785
832
  }
786
833
  /**
787
834
  * Run `fn` exclusively against the persisted outbox: it chains after every
@@ -838,28 +885,55 @@ var OfflineQueue = class {
838
885
  get isDisposed() {
839
886
  return this.disposed;
840
887
  }
841
- /** Append an op to the durable outbox. Returns the persisted op. */
842
- async enqueue(op) {
888
+ /**
889
+ * Capture the current owner and purge generation. Take it before a write's
890
+ * live attempt and pass it to {@link enqueue}.
891
+ */
892
+ ticket() {
893
+ return {
894
+ owner: this.currentOwner?.(),
895
+ generation: this.purgeGeneration
896
+ };
897
+ }
898
+ /**
899
+ * Append an op to the durable outbox. Returns the persisted op. With a
900
+ * `ticket`, the op belongs to the ticket's owner, and the enqueue rejects
901
+ * (code `OFFLINE_QUEUE_PURGED`) when the queue was purged after the ticket
902
+ * was taken, which is what a sign-out during the live attempt does.
903
+ */
904
+ async enqueue(op, ticket) {
843
905
  if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
906
+ const owner = op.owner ?? (ticket ? ticket.owner : this.currentOwner?.());
844
907
  const full = {
845
908
  ...op,
909
+ ...owner ? { owner: { ...owner } } : {},
846
910
  idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
847
911
  enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
848
912
  };
849
- return this.mutate((ops) => ({
850
- next: [...ops, full],
851
- result: full
852
- }), { rejectIfDisposed: true });
913
+ return this.mutate((ops) => {
914
+ if (ticket && ticket.generation !== this.purgeGeneration) {
915
+ const err = /* @__PURE__ */ new Error("amba offline queue was cleared by a sign-out while this write was in flight");
916
+ err.code = "OFFLINE_QUEUE_PURGED";
917
+ throw err;
918
+ }
919
+ return {
920
+ next: [...ops, full],
921
+ result: full
922
+ };
923
+ }, { rejectIfDisposed: true });
853
924
  }
854
- /** Number of ops awaiting replay. */
925
+ /** Number of the current owner's ops awaiting replay. */
855
926
  async pendingCount() {
856
- return (await this.load()).length;
927
+ return this.visible(await this.load()).length;
857
928
  }
858
- /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
929
+ /**
930
+ * Snapshot the current owner's pending ops (a copy — mutating it does not
931
+ * affect the queue). Ops queued by another user stay out of the list.
932
+ */
859
933
  async pending() {
860
- return [...await this.load()];
934
+ return [...this.visible(await this.load())];
861
935
  }
862
- /** Drop every queued op without replaying. */
936
+ /** Drop every queued op, whoever queued it, without replaying. */
863
937
  async clear() {
864
938
  await this.mutate(() => ({
865
939
  next: [],
@@ -867,10 +941,23 @@ var OfflineQueue = class {
867
941
  }));
868
942
  }
869
943
  /**
870
- * Replay the outbox in FIFO order. Each op is removed only after it
871
- * succeeds; the first failure stops the flush and retains the remainder
872
- * (so order is preserved and nothing is dropped mid-batch). Concurrent
873
- * calls share one in-flight flush.
944
+ * Drop every queued op, including one whose replay is in flight right now:
945
+ * that op is not re-queued when its replay fails. The SDKs call this on
946
+ * sign-out so nothing the signed-out user queued survives on the device.
947
+ */
948
+ async purge() {
949
+ this.purgeGeneration += 1;
950
+ await this.clear();
951
+ }
952
+ /**
953
+ * Replay the current owner's ops in FIFO order. Each op is removed only
954
+ * after it succeeds; the first failure stops the flush and retains the
955
+ * remainder (so order is preserved and nothing is dropped mid-batch). Ops
956
+ * another user queued stay held for that user. Ops that can never replay
957
+ * are dropped through `onDropped`: one with no recorded owner (code
958
+ * `OFFLINE_OWNER_UNKNOWN`), and one queued before any session under an
959
+ * anonymous id the install no longer has (code `OFFLINE_OWNER_GONE`).
960
+ * Concurrent calls share one in-flight flush.
874
961
  */
875
962
  async flush() {
876
963
  if (this.disposed) return {
@@ -889,8 +976,26 @@ var OfflineQueue = class {
889
976
  let dropped = 0;
890
977
  for (;;) {
891
978
  if (this.disposed) break;
892
- const head = (await this.load())[0];
979
+ const all = await this.load();
980
+ if (this.currentOwner) {
981
+ const current = this.currentOwner();
982
+ const orphan = all.find((o) => !o.owner || o.owner.appUserId === null && current.anonymousId !== null && o.owner.anonymousId !== current.anonymousId);
983
+ if (orphan) {
984
+ if (!await this.removeOp(orphan)) break;
985
+ dropped += 1;
986
+ this.reportDropped(orphan, orphan.owner ? {
987
+ code: "OFFLINE_OWNER_GONE",
988
+ message: "queued before sign-in under an anonymous id this install no longer has; not replayed"
989
+ } : {
990
+ code: "OFFLINE_OWNER_UNKNOWN",
991
+ message: "queued by an earlier SDK version without an owner; not replayed"
992
+ });
993
+ continue;
994
+ }
995
+ }
996
+ const head = this.visible(all)[0];
893
997
  if (!head) break;
998
+ const generation = this.purgeGeneration;
894
999
  let replayError = void 0;
895
1000
  let ok = false;
896
1001
  try {
@@ -902,32 +1007,20 @@ var OfflineQueue = class {
902
1007
  if (!ok && !isDeterministicError(replayError)) {
903
1008
  await this.mutate((ops) => {
904
1009
  return {
905
- next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
1010
+ next: !ops.some((o) => o.idempotencyKey === head.idempotencyKey) && generation === this.purgeGeneration ? [head, ...ops] : ops,
906
1011
  result: void 0
907
1012
  };
908
1013
  });
909
1014
  break;
910
1015
  }
911
- if (!await this.mutate((ops) => {
912
- const idx = ops.findIndex((o) => o.idempotencyKey === head.idempotencyKey);
913
- if (idx === -1) return {
914
- next: ops,
915
- result: false
916
- };
917
- return {
918
- next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
919
- result: true
920
- };
921
- })) break;
1016
+ if (!await this.removeOp(head)) break;
922
1017
  if (ok) flushed += 1;
923
1018
  else {
924
1019
  dropped += 1;
925
- try {
926
- this.onDropped?.(head, replayError);
927
- } catch {}
1020
+ this.reportDropped(head, replayError);
928
1021
  }
929
1022
  }
930
- const remaining = (await this.load()).length;
1023
+ const remaining = this.visible(await this.load()).length;
931
1024
  return {
932
1025
  flushed,
933
1026
  dropped,
@@ -935,6 +1028,29 @@ var OfflineQueue = class {
935
1028
  };
936
1029
  }
937
1030
  /**
1031
+ * Remove exactly `op` (by idempotency key) under the lock. Robust against a
1032
+ * concurrent enqueue having shifted the array. Resolves false when the op
1033
+ * is already gone.
1034
+ */
1035
+ removeOp(op) {
1036
+ return this.mutate((ops) => {
1037
+ const idx = ops.findIndex((o) => o.idempotencyKey === op.idempotencyKey);
1038
+ if (idx === -1) return {
1039
+ next: ops,
1040
+ result: false
1041
+ };
1042
+ return {
1043
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
1044
+ result: true
1045
+ };
1046
+ });
1047
+ }
1048
+ reportDropped(op, err) {
1049
+ try {
1050
+ this.onDropped?.(op, err);
1051
+ } catch {}
1052
+ }
1053
+ /**
938
1054
  * Install an auto-flush listener on the runtime's `online` event so the
939
1055
  * queue drains the moment connectivity returns. Idempotent — a second
940
1056
  * call is a no-op. Returns a detach function.
@@ -1388,6 +1504,13 @@ let _currentSessionExpiresAt = null;
1388
1504
  let _currentUser = null;
1389
1505
  const BROWSER_IDENTITY_VERSION = 1;
1390
1506
  const DEFAULT_SESSION_TTL_MS = 3600 * 1e3;
1507
+ /**
1508
+ * `AmbaConfig.sessionPersistence` for the current configuration. In
1509
+ * `"memory"` mode no identity snapshot (tokens, user id, anonymous id) is read
1510
+ * from or written to `localStorage`.
1511
+ */
1512
+ let _sessionPersistence = "local";
1513
+ const SESSION_PERSISTENCE_MODES = ["local", "memory"];
1391
1514
  function identityStorageKey(projectId) {
1392
1515
  return `identity:v${BROWSER_IDENTITY_VERSION}:project:${encodeURIComponent(projectId)}`;
1393
1516
  }
@@ -1427,8 +1550,26 @@ function readPersistedIdentity(persistence, projectId) {
1427
1550
  return null;
1428
1551
  }
1429
1552
  }
1553
+ /**
1554
+ * The identity the current core holds, for a `"memory"` re-configure of the
1555
+ * same project: the new core starts from it, so a second `configure()` in the
1556
+ * same page keeps the session.
1557
+ */
1558
+ function liveIdentity(projectId) {
1559
+ if (!coreInstance || _configuredProjectId !== projectId) return null;
1560
+ return {
1561
+ version: BROWSER_IDENTITY_VERSION,
1562
+ project_id: projectId,
1563
+ anonymous_id: coreInstance.anonymousId(),
1564
+ app_user_id: coreInstance.appUserId() ?? null,
1565
+ session_token: _currentSessionToken,
1566
+ refresh_token: _currentRefreshToken,
1567
+ expires_at: _currentSessionExpiresAt,
1568
+ user: _currentUser
1569
+ };
1570
+ }
1430
1571
  function writeIdentitySnapshot() {
1431
- if (!_persistence?.available || !_configuredProjectId || !coreInstance) return;
1572
+ if (_sessionPersistence === "memory" || !_persistence?.available || !_configuredProjectId || !coreInstance) return;
1432
1573
  const snapshot = {
1433
1574
  version: BROWSER_IDENTITY_VERSION,
1434
1575
  project_id: _configuredProjectId,
@@ -1582,12 +1723,29 @@ function getOfflineQueue() {
1582
1723
  store: offlineStore(),
1583
1724
  storageKey: `offline_outbox:${_configuredProjectId ?? "default"}`,
1584
1725
  executor: replayOutboxOp,
1726
+ currentOwner: () => ({
1727
+ appUserId: _currentSessionToken || _currentRefreshToken ? coreInstance?.appUserId() ?? null : null,
1728
+ anonymousId: coreInstance?.anonymousId() ?? null
1729
+ }),
1585
1730
  onDropped: (op, err) => {
1586
1731
  if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1587
1732
  }
1588
1733
  });
1589
1734
  return _offlineQueue;
1590
1735
  }
1736
+ /**
1737
+ * Empty the offline outbox on sign-out, including writes queued on earlier
1738
+ * page loads, so nothing the signed-out user queued replays under the next
1739
+ * session.
1740
+ */
1741
+ async function purgeOfflineOutbox() {
1742
+ if (!_persistence) return;
1743
+ try {
1744
+ await getOfflineQueue().purge();
1745
+ } catch (err) {
1746
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
1747
+ }
1748
+ }
1591
1749
  async function insertDirect(name, row) {
1592
1750
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
1593
1751
  }
@@ -1711,8 +1869,8 @@ function buildAiStreamRequest(path, request) {
1711
1869
  * resolved default-host id is cached for subsequent invokes.
1712
1870
  */
1713
1871
  async function resolveFunctionUrl(name) {
1714
- if (_configuredFunctionsBaseUrl) return `${_configuredFunctionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
1715
- return `https://${await resolveFunctionsProjectId()}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
1872
+ if (_configuredFunctionsBaseUrl) return `${_configuredFunctionsBaseUrl.replace(/\/$/, "")}/${pathSegment(name)}`;
1873
+ return `https://${await resolveFunctionsProjectId()}.${DEFAULT_FUNCTIONS_HOST}/${pathSegment(name)}`;
1716
1874
  }
1717
1875
  async function resolveFunctionsProjectId() {
1718
1876
  if (_resolvedFunctionsProjectId) return _resolvedFunctionsProjectId;
@@ -1871,8 +2029,10 @@ const Amba = {
1871
2029
  if (explicitWasmInput !== void 0) await initCore({ module_or_path: explicitWasmInput });
1872
2030
  else await initCore(); // Hermes-stripped Node-init block (DX-15) — see scripts/build-rn.mjs
1873
2031
  }
2032
+ const sessionPersistence = config.sessionPersistence ?? "local";
2033
+ if (!SESSION_PERSISTENCE_MODES.includes(sessionPersistence)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: sessionPersistence must be \"local\" or \"memory\"");
1874
2034
  const persistence = new LocalStoragePersistence(config.storagePrefix ?? "amba");
1875
- const restoredIdentity = readPersistedIdentity(persistence, config.projectId);
2035
+ const restoredIdentity = sessionPersistence === "memory" ? liveIdentity(config.projectId) : readPersistedIdentity(persistence, config.projectId);
1876
2036
  let nextCore;
1877
2037
  try {
1878
2038
  const { wasmClass } = await getCoreRuntime();
@@ -1897,6 +2057,8 @@ const Amba = {
1897
2057
  }
1898
2058
  await teardownOffline();
1899
2059
  _persistence = persistence;
2060
+ _sessionPersistence = sessionPersistence;
2061
+ if (sessionPersistence === "memory") persistence.delete(identityStorageKey(config.projectId));
1900
2062
  coreInstance = nextCore;
1901
2063
  _configuredProjectId = config.projectId;
1902
2064
  _configuredApiUrl = config.apiUrl ?? DEFAULT_API_URL;
@@ -1929,6 +2091,7 @@ const Amba = {
1929
2091
  },
1930
2092
  events: {
1931
2093
  track: async (event, properties, options) => {
2094
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
1932
2095
  try {
1933
2096
  await coreCall((c) => c.track(event, properties ? encodeJson(properties) : void 0, options?.telemetry));
1934
2097
  } catch (err) {
@@ -1939,7 +2102,7 @@ const Amba = {
1939
2102
  properties,
1940
2103
  telemetry: options?.telemetry,
1941
2104
  occurredAt: (/* @__PURE__ */ new Date()).toISOString()
1942
- });
2105
+ }, outboxTicket);
1943
2106
  return;
1944
2107
  }
1945
2108
  throw err;
@@ -2066,6 +2229,7 @@ const Amba = {
2066
2229
  _currentSessionExpiresAt = null;
2067
2230
  _currentUser = null;
2068
2231
  writeIdentitySnapshot();
2232
+ await purgeOfflineOutbox();
2069
2233
  notifyAuthSubscribers(null);
2070
2234
  },
2071
2235
  refresh: async () => {
@@ -2141,6 +2305,7 @@ const Amba = {
2141
2305
  ...row,
2142
2306
  idempotency_key: idempotencyKey
2143
2307
  };
2308
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2144
2309
  try {
2145
2310
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(wireRow)));
2146
2311
  } catch (err) {
@@ -2150,7 +2315,7 @@ const Amba = {
2150
2315
  collection: name,
2151
2316
  payload: row,
2152
2317
  idempotencyKey
2153
- });
2318
+ }, outboxTicket);
2154
2319
  return {
2155
2320
  ...row,
2156
2321
  _amba_pending: true
@@ -2162,39 +2327,45 @@ const Amba = {
2162
2327
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
2163
2328
  },
2164
2329
  update: async (name, id, set) => {
2165
- if (_offlineEnabled) try {
2166
- return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2167
- } catch (err) {
2168
- if (isOfflineError(err)) {
2169
- await getOfflineQueue().enqueue({
2170
- kind: "collection.update",
2171
- collection: name,
2172
- id,
2173
- payload: set
2174
- });
2175
- return {
2176
- id,
2177
- ...set,
2178
- _amba_pending: true
2179
- };
2330
+ if (_offlineEnabled) {
2331
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2332
+ try {
2333
+ return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2334
+ } catch (err) {
2335
+ if (isOfflineError(err)) {
2336
+ await getOfflineQueue().enqueue({
2337
+ kind: "collection.update",
2338
+ collection: name,
2339
+ id,
2340
+ payload: set
2341
+ }, outboxTicket);
2342
+ return {
2343
+ id,
2344
+ ...set,
2345
+ _amba_pending: true
2346
+ };
2347
+ }
2348
+ throw err;
2180
2349
  }
2181
- throw err;
2182
2350
  }
2183
2351
  return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2184
2352
  },
2185
2353
  delete: async (name, id) => {
2186
- if (_offlineEnabled) try {
2187
- return await coreCall((c) => c.collectionsDelete(name, id));
2188
- } catch (err) {
2189
- if (isOfflineError(err)) {
2190
- await getOfflineQueue().enqueue({
2191
- kind: "collection.delete",
2192
- collection: name,
2193
- id
2194
- });
2195
- return { data: { deleted: true } };
2354
+ if (_offlineEnabled) {
2355
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2356
+ try {
2357
+ return await coreCall((c) => c.collectionsDelete(name, id));
2358
+ } catch (err) {
2359
+ if (isOfflineError(err)) {
2360
+ await getOfflineQueue().enqueue({
2361
+ kind: "collection.delete",
2362
+ collection: name,
2363
+ id
2364
+ }, outboxTicket);
2365
+ return { data: { deleted: true } };
2366
+ }
2367
+ throw err;
2196
2368
  }
2197
- throw err;
2198
2369
  }
2199
2370
  return await coreCall((c) => c.collectionsDelete(name, id));
2200
2371
  },
@@ -2337,7 +2508,7 @@ const Amba = {
2337
2508
  media: { catalog: async (slug) => {
2338
2509
  if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before reading a media catalog.");
2339
2510
  if (!slug || slug.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.media.catalog: a catalog slug is required.");
2340
- const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${encodeURIComponent(slug)}`;
2511
+ const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${pathSegment(slug)}`;
2341
2512
  let resp;
2342
2513
  try {
2343
2514
  resp = await fetch(url, {
@@ -2672,7 +2843,7 @@ function getCoreWasm() {
2672
2843
  * `verify-sdk-version-coherence` GH action gates every publish on the
2673
2844
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
2674
2845
  */
2675
- const SDK_VERSION = "4.0.10";
2846
+ const SDK_VERSION = "4.0.11";
2676
2847
 
2677
2848
  //#endregion
2678
2849
  export { Amba, AmbaApiError, AmbaGroupScopedClient, CollectionSubscription, ConversationSubscription, GamificationSubscription, SDK_VERSION, getCoreWasm, withOptimistic };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@layers/amba-web",
3
- "version": "4.0.10",
3
+ "version": "4.0.11",
4
4
  "description": "amba SDK for browser apps — full SDK 4.0 surface (34 modules: auth + magic link, users, sessions, sync, collections + vector search, storage + list/delete/download, push, entitlements, flags, config, deep links, onboarding, content, messaging, friends, groups, feeds, reviews, moderation, roles, referrals, achievements, xp, streaks, challenges, leaderboards, leagues, currencies, inventory, stores, catalog, ai, diagnostics).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "sideEffects": false,
34
34
  "dependencies": {
35
- "@layers/amba-core": "^4.0.10"
35
+ "@layers/amba-core": "^4.0.11"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22.13.14",