@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.
@@ -214,6 +214,20 @@ function toAmbaApiError(err) {
214
214
  return new AmbaApiError("UNKNOWN_ERROR", String(err));
215
215
  }
216
216
 
217
+ //#endregion
218
+ //#region src/path-segment.ts
219
+ /**
220
+ * Encode one caller-supplied id, key, slug or name as a single URL path
221
+ * segment. `encodeURIComponent` escapes `/`, `?` and `#`, so the value stays
222
+ * inside its segment. `.` and `..` are refused because URL resolution treats
223
+ * them as steps to another route, and the empty string is refused because it
224
+ * collapses onto the parent route.
225
+ */
226
+ function pathSegment(value) {
227
+ if (value === "" || value === "." || value === "..") throw new AmbaApiError("VALIDATION_ERROR", `path segment must not be ${value === "" ? "empty" : `'${value}'`}`);
228
+ return encodeURIComponent(value);
229
+ }
230
+
217
231
  //#endregion
218
232
  //#region src/realtime.ts
219
233
  const INITIAL_BACKOFF_MS = 500;
@@ -253,7 +267,7 @@ var CollectionSubscription = class {
253
267
  if (!this.ctx.authHeaders()) throw new Error("Amba.collection().subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
254
268
  const qs = Object.keys(this.filters).length > 0 ? `?where=${encodeURIComponent(JSON.stringify(this.filters))}` : "";
255
269
  return openStream({
256
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${encodeURIComponent(this.name)}${qs}`,
270
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/collections/${pathSegment(this.name)}${qs}`,
257
271
  authHeaders: this.ctx.authHeaders,
258
272
  onChange: cb,
259
273
  options
@@ -374,7 +388,7 @@ var ConversationSubscription = class {
374
388
  subscribe(cb, options = {}) {
375
389
  if (!this.ctx.authHeaders()) throw new Error("Amba.messaging.conversation().subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
376
390
  return openStream({
377
- url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${encodeURIComponent(this.conversationId)}`,
391
+ url: `${resolveRealtimeBaseUrl(this.ctx.baseUrl)}/v1/client/realtime/conversations/${pathSegment(this.conversationId)}`,
378
392
  authHeaders: this.ctx.authHeaders,
379
393
  onChange: cb,
380
394
  options
@@ -697,6 +711,19 @@ async function* invokeAiStream(args) {
697
711
  //#endregion
698
712
  //#region src/resilience.ts
699
713
  /**
714
+ * Whether `op` belongs to `current`. An op queued under a user id matches only
715
+ * that user id. An op queued before any session matches the same anonymous id,
716
+ * so a guest's offline write lands once that guest's session starts. An op
717
+ * with no recorded owner (queued by an SDK version before owners existed)
718
+ * matches nobody.
719
+ */
720
+ function outboxOwnerMatches(op, current) {
721
+ const owner = op.owner;
722
+ if (!owner) return false;
723
+ if (owner.appUserId) return owner.appUserId === current.appUserId;
724
+ return owner.anonymousId !== null && owner.anonymousId === current.anonymousId;
725
+ }
726
+ /**
700
727
  * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
701
728
  * exists; under Node / SSR it doesn't, so we treat "unknown" as online
702
729
  * (the executor's own error handling then decides).
@@ -780,8 +807,18 @@ var OfflineQueue = class {
780
807
  * SDK surface the loss (debug log) instead of silently discarding.
781
808
  */
782
809
  onDropped;
810
+ /**
811
+ * The identity that is signed in right now. When set, enqueue stamps it on
812
+ * each op and flush/pending only touch ops it owns.
813
+ */
814
+ currentOwner;
783
815
  flushing = null;
784
816
  /**
817
+ * Bumped by `purge()`. A replay that started before a purge sees the change
818
+ * and does not re-queue its op.
819
+ */
820
+ purgeGeneration = 0;
821
+ /**
785
822
  * Serializes every read-modify-write of the persisted outbox. Each mutation
786
823
  * chains onto the previous one's promise, so concurrent `enqueue`s — and an
787
824
  * `enqueue` racing the per-op removal inside `flush` — can never read a stale
@@ -804,6 +841,16 @@ var OfflineQueue = class {
804
841
  this.executor = opts.executor;
805
842
  this.storageKey = opts.storageKey ?? "offline_outbox";
806
843
  this.onDropped = opts.onDropped;
844
+ this.currentOwner = opts.currentOwner;
845
+ }
846
+ /**
847
+ * The ops the current owner may see and replay. Without an owner source
848
+ * every op qualifies.
849
+ */
850
+ visible(ops) {
851
+ if (!this.currentOwner) return ops;
852
+ const current = this.currentOwner();
853
+ return ops.filter((op) => outboxOwnerMatches(op, current));
807
854
  }
808
855
  /**
809
856
  * Run `fn` exclusively against the persisted outbox: it chains after every
@@ -860,28 +907,55 @@ var OfflineQueue = class {
860
907
  get isDisposed() {
861
908
  return this.disposed;
862
909
  }
863
- /** Append an op to the durable outbox. Returns the persisted op. */
864
- async enqueue(op) {
910
+ /**
911
+ * Capture the current owner and purge generation. Take it before a write's
912
+ * live attempt and pass it to {@link enqueue}.
913
+ */
914
+ ticket() {
915
+ return {
916
+ owner: this.currentOwner?.(),
917
+ generation: this.purgeGeneration
918
+ };
919
+ }
920
+ /**
921
+ * Append an op to the durable outbox. Returns the persisted op. With a
922
+ * `ticket`, the op belongs to the ticket's owner, and the enqueue rejects
923
+ * (code `OFFLINE_QUEUE_PURGED`) when the queue was purged after the ticket
924
+ * was taken, which is what a sign-out during the live attempt does.
925
+ */
926
+ async enqueue(op, ticket) {
865
927
  if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
928
+ const owner = op.owner ?? (ticket ? ticket.owner : this.currentOwner?.());
866
929
  const full = {
867
930
  ...op,
931
+ ...owner ? { owner: { ...owner } } : {},
868
932
  idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
869
933
  enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
870
934
  };
871
- return this.mutate((ops) => ({
872
- next: [...ops, full],
873
- result: full
874
- }), { rejectIfDisposed: true });
935
+ return this.mutate((ops) => {
936
+ if (ticket && ticket.generation !== this.purgeGeneration) {
937
+ const err = /* @__PURE__ */ new Error("amba offline queue was cleared by a sign-out while this write was in flight");
938
+ err.code = "OFFLINE_QUEUE_PURGED";
939
+ throw err;
940
+ }
941
+ return {
942
+ next: [...ops, full],
943
+ result: full
944
+ };
945
+ }, { rejectIfDisposed: true });
875
946
  }
876
- /** Number of ops awaiting replay. */
947
+ /** Number of the current owner's ops awaiting replay. */
877
948
  async pendingCount() {
878
- return (await this.load()).length;
949
+ return this.visible(await this.load()).length;
879
950
  }
880
- /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
951
+ /**
952
+ * Snapshot the current owner's pending ops (a copy — mutating it does not
953
+ * affect the queue). Ops queued by another user stay out of the list.
954
+ */
881
955
  async pending() {
882
- return [...await this.load()];
956
+ return [...this.visible(await this.load())];
883
957
  }
884
- /** Drop every queued op without replaying. */
958
+ /** Drop every queued op, whoever queued it, without replaying. */
885
959
  async clear() {
886
960
  await this.mutate(() => ({
887
961
  next: [],
@@ -889,10 +963,23 @@ var OfflineQueue = class {
889
963
  }));
890
964
  }
891
965
  /**
892
- * Replay the outbox in FIFO order. Each op is removed only after it
893
- * succeeds; the first failure stops the flush and retains the remainder
894
- * (so order is preserved and nothing is dropped mid-batch). Concurrent
895
- * calls share one in-flight flush.
966
+ * Drop every queued op, including one whose replay is in flight right now:
967
+ * that op is not re-queued when its replay fails. The SDKs call this on
968
+ * sign-out so nothing the signed-out user queued survives on the device.
969
+ */
970
+ async purge() {
971
+ this.purgeGeneration += 1;
972
+ await this.clear();
973
+ }
974
+ /**
975
+ * Replay the current owner's ops in FIFO order. Each op is removed only
976
+ * after it succeeds; the first failure stops the flush and retains the
977
+ * remainder (so order is preserved and nothing is dropped mid-batch). Ops
978
+ * another user queued stay held for that user. Ops that can never replay
979
+ * are dropped through `onDropped`: one with no recorded owner (code
980
+ * `OFFLINE_OWNER_UNKNOWN`), and one queued before any session under an
981
+ * anonymous id the install no longer has (code `OFFLINE_OWNER_GONE`).
982
+ * Concurrent calls share one in-flight flush.
896
983
  */
897
984
  async flush() {
898
985
  if (this.disposed) return {
@@ -911,8 +998,26 @@ var OfflineQueue = class {
911
998
  let dropped = 0;
912
999
  for (;;) {
913
1000
  if (this.disposed) break;
914
- const head = (await this.load())[0];
1001
+ const all = await this.load();
1002
+ if (this.currentOwner) {
1003
+ const current = this.currentOwner();
1004
+ const orphan = all.find((o) => !o.owner || o.owner.appUserId === null && current.anonymousId !== null && o.owner.anonymousId !== current.anonymousId);
1005
+ if (orphan) {
1006
+ if (!await this.removeOp(orphan)) break;
1007
+ dropped += 1;
1008
+ this.reportDropped(orphan, orphan.owner ? {
1009
+ code: "OFFLINE_OWNER_GONE",
1010
+ message: "queued before sign-in under an anonymous id this install no longer has; not replayed"
1011
+ } : {
1012
+ code: "OFFLINE_OWNER_UNKNOWN",
1013
+ message: "queued by an earlier SDK version without an owner; not replayed"
1014
+ });
1015
+ continue;
1016
+ }
1017
+ }
1018
+ const head = this.visible(all)[0];
915
1019
  if (!head) break;
1020
+ const generation = this.purgeGeneration;
916
1021
  let replayError = void 0;
917
1022
  let ok = false;
918
1023
  try {
@@ -924,32 +1029,20 @@ var OfflineQueue = class {
924
1029
  if (!ok && !isDeterministicError(replayError)) {
925
1030
  await this.mutate((ops) => {
926
1031
  return {
927
- next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
1032
+ next: !ops.some((o) => o.idempotencyKey === head.idempotencyKey) && generation === this.purgeGeneration ? [head, ...ops] : ops,
928
1033
  result: void 0
929
1034
  };
930
1035
  });
931
1036
  break;
932
1037
  }
933
- if (!await this.mutate((ops) => {
934
- const idx = ops.findIndex((o) => o.idempotencyKey === head.idempotencyKey);
935
- if (idx === -1) return {
936
- next: ops,
937
- result: false
938
- };
939
- return {
940
- next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
941
- result: true
942
- };
943
- })) break;
1038
+ if (!await this.removeOp(head)) break;
944
1039
  if (ok) flushed += 1;
945
1040
  else {
946
1041
  dropped += 1;
947
- try {
948
- this.onDropped?.(head, replayError);
949
- } catch {}
1042
+ this.reportDropped(head, replayError);
950
1043
  }
951
1044
  }
952
- const remaining = (await this.load()).length;
1045
+ const remaining = this.visible(await this.load()).length;
953
1046
  return {
954
1047
  flushed,
955
1048
  dropped,
@@ -957,6 +1050,29 @@ var OfflineQueue = class {
957
1050
  };
958
1051
  }
959
1052
  /**
1053
+ * Remove exactly `op` (by idempotency key) under the lock. Robust against a
1054
+ * concurrent enqueue having shifted the array. Resolves false when the op
1055
+ * is already gone.
1056
+ */
1057
+ removeOp(op) {
1058
+ return this.mutate((ops) => {
1059
+ const idx = ops.findIndex((o) => o.idempotencyKey === op.idempotencyKey);
1060
+ if (idx === -1) return {
1061
+ next: ops,
1062
+ result: false
1063
+ };
1064
+ return {
1065
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
1066
+ result: true
1067
+ };
1068
+ });
1069
+ }
1070
+ reportDropped(op, err) {
1071
+ try {
1072
+ this.onDropped?.(op, err);
1073
+ } catch {}
1074
+ }
1075
+ /**
960
1076
  * Install an auto-flush listener on the runtime's `online` event so the
961
1077
  * queue drains the moment connectivity returns. Idempotent — a second
962
1078
  * call is a no-op. Returns a detach function.
@@ -1390,6 +1506,13 @@ let _currentSessionExpiresAt = null;
1390
1506
  let _currentUser = null;
1391
1507
  const BROWSER_IDENTITY_VERSION = 1;
1392
1508
  const DEFAULT_SESSION_TTL_MS = 3600 * 1e3;
1509
+ /**
1510
+ * `AmbaConfig.sessionPersistence` for the current configuration. In
1511
+ * `"memory"` mode no identity snapshot (tokens, user id, anonymous id) is read
1512
+ * from or written to `localStorage`.
1513
+ */
1514
+ let _sessionPersistence = "local";
1515
+ const SESSION_PERSISTENCE_MODES = ["local", "memory"];
1393
1516
  function identityStorageKey(projectId) {
1394
1517
  return `identity:v${BROWSER_IDENTITY_VERSION}:project:${encodeURIComponent(projectId)}`;
1395
1518
  }
@@ -1429,8 +1552,26 @@ function readPersistedIdentity(persistence, projectId) {
1429
1552
  return null;
1430
1553
  }
1431
1554
  }
1555
+ /**
1556
+ * The identity the current core holds, for a `"memory"` re-configure of the
1557
+ * same project: the new core starts from it, so a second `configure()` in the
1558
+ * same page keeps the session.
1559
+ */
1560
+ function liveIdentity(projectId) {
1561
+ if (!coreInstance || _configuredProjectId !== projectId) return null;
1562
+ return {
1563
+ version: BROWSER_IDENTITY_VERSION,
1564
+ project_id: projectId,
1565
+ anonymous_id: coreInstance.anonymousId(),
1566
+ app_user_id: coreInstance.appUserId() ?? null,
1567
+ session_token: _currentSessionToken,
1568
+ refresh_token: _currentRefreshToken,
1569
+ expires_at: _currentSessionExpiresAt,
1570
+ user: _currentUser
1571
+ };
1572
+ }
1432
1573
  function writeIdentitySnapshot() {
1433
- if (!_persistence?.available || !_configuredProjectId || !coreInstance) return;
1574
+ if (_sessionPersistence === "memory" || !_persistence?.available || !_configuredProjectId || !coreInstance) return;
1434
1575
  const snapshot = {
1435
1576
  version: BROWSER_IDENTITY_VERSION,
1436
1577
  project_id: _configuredProjectId,
@@ -1584,12 +1725,29 @@ function getOfflineQueue() {
1584
1725
  store: offlineStore(),
1585
1726
  storageKey: `offline_outbox:${_configuredProjectId ?? "default"}`,
1586
1727
  executor: replayOutboxOp,
1728
+ currentOwner: () => ({
1729
+ appUserId: _currentSessionToken || _currentRefreshToken ? coreInstance?.appUserId() ?? null : null,
1730
+ anonymousId: coreInstance?.anonymousId() ?? null
1731
+ }),
1587
1732
  onDropped: (op, err) => {
1588
1733
  if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1589
1734
  }
1590
1735
  });
1591
1736
  return _offlineQueue;
1592
1737
  }
1738
+ /**
1739
+ * Empty the offline outbox on sign-out, including writes queued on earlier
1740
+ * page loads, so nothing the signed-out user queued replays under the next
1741
+ * session.
1742
+ */
1743
+ async function purgeOfflineOutbox() {
1744
+ if (!_persistence) return;
1745
+ try {
1746
+ await getOfflineQueue().purge();
1747
+ } catch (err) {
1748
+ if (typeof console !== "undefined") console.warn("[amba] signOut could not clear the offline outbox", err);
1749
+ }
1750
+ }
1593
1751
  async function insertDirect(name, row) {
1594
1752
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
1595
1753
  }
@@ -1713,8 +1871,8 @@ function buildAiStreamRequest(path, request) {
1713
1871
  * resolved default-host id is cached for subsequent invokes.
1714
1872
  */
1715
1873
  async function resolveFunctionUrl(name) {
1716
- if (_configuredFunctionsBaseUrl) return `${_configuredFunctionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
1717
- return `https://${await resolveFunctionsProjectId()}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
1874
+ if (_configuredFunctionsBaseUrl) return `${_configuredFunctionsBaseUrl.replace(/\/$/, "")}/${pathSegment(name)}`;
1875
+ return `https://${await resolveFunctionsProjectId()}.${DEFAULT_FUNCTIONS_HOST}/${pathSegment(name)}`;
1718
1876
  }
1719
1877
  async function resolveFunctionsProjectId() {
1720
1878
  if (_resolvedFunctionsProjectId) return _resolvedFunctionsProjectId;
@@ -1873,8 +2031,10 @@ const Amba = {
1873
2031
  if (explicitWasmInput !== void 0) await initCore({ module_or_path: explicitWasmInput });
1874
2032
  else await initCore();
1875
2033
  }
2034
+ const sessionPersistence = config.sessionPersistence ?? "local";
2035
+ if (!SESSION_PERSISTENCE_MODES.includes(sessionPersistence)) throw new AmbaApiError("INVALID_CONFIG", "Amba.configure: sessionPersistence must be \"local\" or \"memory\"");
1876
2036
  const persistence = new LocalStoragePersistence(config.storagePrefix ?? "amba");
1877
- const restoredIdentity = readPersistedIdentity(persistence, config.projectId);
2037
+ const restoredIdentity = sessionPersistence === "memory" ? liveIdentity(config.projectId) : readPersistedIdentity(persistence, config.projectId);
1878
2038
  let nextCore;
1879
2039
  try {
1880
2040
  const { wasmClass } = await getCoreRuntime();
@@ -1899,6 +2059,8 @@ const Amba = {
1899
2059
  }
1900
2060
  await teardownOffline();
1901
2061
  _persistence = persistence;
2062
+ _sessionPersistence = sessionPersistence;
2063
+ if (sessionPersistence === "memory") persistence.delete(identityStorageKey(config.projectId));
1902
2064
  coreInstance = nextCore;
1903
2065
  _configuredProjectId = config.projectId;
1904
2066
  _configuredApiUrl = config.apiUrl ?? DEFAULT_API_URL;
@@ -1931,6 +2093,7 @@ const Amba = {
1931
2093
  },
1932
2094
  events: {
1933
2095
  track: async (event, properties, options) => {
2096
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
1934
2097
  try {
1935
2098
  await coreCall((c) => c.track(event, properties ? encodeJson(properties) : void 0, options?.telemetry));
1936
2099
  } catch (err) {
@@ -1941,7 +2104,7 @@ const Amba = {
1941
2104
  properties,
1942
2105
  telemetry: options?.telemetry,
1943
2106
  occurredAt: (/* @__PURE__ */ new Date()).toISOString()
1944
- });
2107
+ }, outboxTicket);
1945
2108
  return;
1946
2109
  }
1947
2110
  throw err;
@@ -2068,6 +2231,7 @@ const Amba = {
2068
2231
  _currentSessionExpiresAt = null;
2069
2232
  _currentUser = null;
2070
2233
  writeIdentitySnapshot();
2234
+ await purgeOfflineOutbox();
2071
2235
  notifyAuthSubscribers(null);
2072
2236
  },
2073
2237
  refresh: async () => {
@@ -2143,6 +2307,7 @@ const Amba = {
2143
2307
  ...row,
2144
2308
  idempotency_key: idempotencyKey
2145
2309
  };
2310
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2146
2311
  try {
2147
2312
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(wireRow)));
2148
2313
  } catch (err) {
@@ -2152,7 +2317,7 @@ const Amba = {
2152
2317
  collection: name,
2153
2318
  payload: row,
2154
2319
  idempotencyKey
2155
- });
2320
+ }, outboxTicket);
2156
2321
  return {
2157
2322
  ...row,
2158
2323
  _amba_pending: true
@@ -2164,39 +2329,45 @@ const Amba = {
2164
2329
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
2165
2330
  },
2166
2331
  update: async (name, id, set) => {
2167
- if (_offlineEnabled) try {
2168
- return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2169
- } catch (err) {
2170
- if (isOfflineError(err)) {
2171
- await getOfflineQueue().enqueue({
2172
- kind: "collection.update",
2173
- collection: name,
2174
- id,
2175
- payload: set
2176
- });
2177
- return {
2178
- id,
2179
- ...set,
2180
- _amba_pending: true
2181
- };
2332
+ if (_offlineEnabled) {
2333
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2334
+ try {
2335
+ return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2336
+ } catch (err) {
2337
+ if (isOfflineError(err)) {
2338
+ await getOfflineQueue().enqueue({
2339
+ kind: "collection.update",
2340
+ collection: name,
2341
+ id,
2342
+ payload: set
2343
+ }, outboxTicket);
2344
+ return {
2345
+ id,
2346
+ ...set,
2347
+ _amba_pending: true
2348
+ };
2349
+ }
2350
+ throw err;
2182
2351
  }
2183
- throw err;
2184
2352
  }
2185
2353
  return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
2186
2354
  },
2187
2355
  delete: async (name, id) => {
2188
- if (_offlineEnabled) try {
2189
- return await coreCall((c) => c.collectionsDelete(name, id));
2190
- } catch (err) {
2191
- if (isOfflineError(err)) {
2192
- await getOfflineQueue().enqueue({
2193
- kind: "collection.delete",
2194
- collection: name,
2195
- id
2196
- });
2197
- return { data: { deleted: true } };
2356
+ if (_offlineEnabled) {
2357
+ const outboxTicket = _offlineEnabled ? getOfflineQueue().ticket() : void 0;
2358
+ try {
2359
+ return await coreCall((c) => c.collectionsDelete(name, id));
2360
+ } catch (err) {
2361
+ if (isOfflineError(err)) {
2362
+ await getOfflineQueue().enqueue({
2363
+ kind: "collection.delete",
2364
+ collection: name,
2365
+ id
2366
+ }, outboxTicket);
2367
+ return { data: { deleted: true } };
2368
+ }
2369
+ throw err;
2198
2370
  }
2199
- throw err;
2200
2371
  }
2201
2372
  return await coreCall((c) => c.collectionsDelete(name, id));
2202
2373
  },
@@ -2339,7 +2510,7 @@ const Amba = {
2339
2510
  media: { catalog: async (slug) => {
2340
2511
  if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before reading a media catalog.");
2341
2512
  if (!slug || slug.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.media.catalog: a catalog slug is required.");
2342
- const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${encodeURIComponent(slug)}`;
2513
+ const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${pathSegment(slug)}`;
2343
2514
  let resp;
2344
2515
  try {
2345
2516
  resp = await fetch(url, {
@@ -2674,7 +2845,7 @@ function getCoreWasm() {
2674
2845
  * `verify-sdk-version-coherence` GH action gates every publish on the
2675
2846
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
2676
2847
  */
2677
- const SDK_VERSION = "4.0.10";
2848
+ const SDK_VERSION = "4.0.11";
2678
2849
 
2679
2850
  //#endregion
2680
2851
  //#region src/browser.ts