@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.
package/dist/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;
@@ -1882,8 +2040,10 @@ const Amba = {
1882
2040
  await initCore({ module_or_path: await readFile(fileURLToPath(wasmJsUrl)) });
1883
2041
  } else await initCore();
1884
2042
  }
2043
+ const sessionPersistence = config.sessionPersistence ?? "local";
2044
+ if (!SESSION_PERSISTENCE_MODES.includes(sessionPersistence)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: sessionPersistence must be \"local\" or \"memory\"");
1885
2045
  const persistence = new LocalStoragePersistence(config.storagePrefix ?? "amba");
1886
- const restoredIdentity = readPersistedIdentity(persistence, config.projectId);
2046
+ const restoredIdentity = sessionPersistence === "memory" ? liveIdentity(config.projectId) : readPersistedIdentity(persistence, config.projectId);
1887
2047
  let nextCore;
1888
2048
  try {
1889
2049
  const { wasmClass } = await getCoreRuntime();
@@ -1908,6 +2068,8 @@ const Amba = {
1908
2068
  }
1909
2069
  await teardownOffline();
1910
2070
  _persistence = persistence;
2071
+ _sessionPersistence = sessionPersistence;
2072
+ if (sessionPersistence === "memory") persistence.delete(identityStorageKey(config.projectId));
1911
2073
  coreInstance = nextCore;
1912
2074
  _configuredProjectId = config.projectId;
1913
2075
  _configuredApiUrl = config.apiUrl ?? DEFAULT_API_URL;
@@ -1940,6 +2102,7 @@ const Amba = {
1940
2102
  },
1941
2103
  events: {
1942
2104
  track: async (event, properties, options) => {
2105
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
1943
2106
  try {
1944
2107
  await coreCall((c) => c.track(event, properties ? encodeJson(properties) : void 0, options?.telemetry));
1945
2108
  } catch (err) {
@@ -1950,7 +2113,7 @@ const Amba = {
1950
2113
  properties,
1951
2114
  telemetry: options?.telemetry,
1952
2115
  occurredAt: (/* @__PURE__ */ new Date()).toISOString()
1953
- });
2116
+ }, outboxTicket);
1954
2117
  return;
1955
2118
  }
1956
2119
  throw err;
@@ -2077,6 +2240,7 @@ const Amba = {
2077
2240
  _currentSessionExpiresAt = null;
2078
2241
  _currentUser = null;
2079
2242
  writeIdentitySnapshot();
2243
+ await purgeOfflineOutbox();
2080
2244
  notifyAuthSubscribers(null);
2081
2245
  },
2082
2246
  refresh: async () => {
@@ -2152,6 +2316,7 @@ const Amba = {
2152
2316
  ...row,
2153
2317
  idempotency_key: idempotencyKey
2154
2318
  };
2319
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2155
2320
  try {
2156
2321
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(wireRow)));
2157
2322
  } catch (err) {
@@ -2161,7 +2326,7 @@ const Amba = {
2161
2326
  collection: name,
2162
2327
  payload: row,
2163
2328
  idempotencyKey
2164
- });
2329
+ }, outboxTicket);
2165
2330
  return {
2166
2331
  ...row,
2167
2332
  _amba_pending: true
@@ -2173,39 +2338,45 @@ const Amba = {
2173
2338
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
2174
2339
  },
2175
2340
  update: async (name, id, set) => {
2176
- if (_offlineEnabled) try {
2177
- return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2178
- } catch (err) {
2179
- if (isOfflineError(err)) {
2180
- await getOfflineQueue().enqueue({
2181
- kind: "collection.update",
2182
- collection: name,
2183
- id,
2184
- payload: set
2185
- });
2186
- return {
2187
- id,
2188
- ...set,
2189
- _amba_pending: true
2190
- };
2341
+ if (_offlineEnabled) {
2342
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2343
+ try {
2344
+ return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2345
+ } catch (err) {
2346
+ if (isOfflineError(err)) {
2347
+ await getOfflineQueue().enqueue({
2348
+ kind: "collection.update",
2349
+ collection: name,
2350
+ id,
2351
+ payload: set
2352
+ }, outboxTicket);
2353
+ return {
2354
+ id,
2355
+ ...set,
2356
+ _amba_pending: true
2357
+ };
2358
+ }
2359
+ throw err;
2191
2360
  }
2192
- throw err;
2193
2361
  }
2194
2362
  return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2195
2363
  },
2196
2364
  delete: async (name, id) => {
2197
- if (_offlineEnabled) try {
2198
- return await coreCall((c) => c.collectionsDelete(name, id));
2199
- } catch (err) {
2200
- if (isOfflineError(err)) {
2201
- await getOfflineQueue().enqueue({
2202
- kind: "collection.delete",
2203
- collection: name,
2204
- id
2205
- });
2206
- return { data: { deleted: true } };
2365
+ if (_offlineEnabled) {
2366
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2367
+ try {
2368
+ return await coreCall((c) => c.collectionsDelete(name, id));
2369
+ } catch (err) {
2370
+ if (isOfflineError(err)) {
2371
+ await getOfflineQueue().enqueue({
2372
+ kind: "collection.delete",
2373
+ collection: name,
2374
+ id
2375
+ }, outboxTicket);
2376
+ return { data: { deleted: true } };
2377
+ }
2378
+ throw err;
2207
2379
  }
2208
- throw err;
2209
2380
  }
2210
2381
  return await coreCall((c) => c.collectionsDelete(name, id));
2211
2382
  },
@@ -2348,7 +2519,7 @@ const Amba = {
2348
2519
  media: { catalog: async (slug) => {
2349
2520
  if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before reading a media catalog.");
2350
2521
  if (!slug || slug.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.media.catalog: a catalog slug is required.");
2351
- const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${encodeURIComponent(slug)}`;
2522
+ const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${pathSegment(slug)}`;
2352
2523
  let resp;
2353
2524
  try {
2354
2525
  resp = await fetch(url, {
@@ -2683,7 +2854,7 @@ function getCoreWasm() {
2683
2854
  * `verify-sdk-version-coherence` GH action gates every publish on the
2684
2855
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
2685
2856
  */
2686
- const SDK_VERSION = "4.0.10";
2857
+ const SDK_VERSION = "4.0.11";
2687
2858
 
2688
2859
  //#endregion
2689
2860
  export { Amba, AmbaApiError, AmbaGroupScopedClient, CollectionSubscription, ConversationSubscription, GamificationSubscription, SDK_VERSION, getCoreWasm, withOptimistic };