@layers/amba-web 4.0.4 → 4.0.5

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-rn/index.js CHANGED
@@ -39,11 +39,89 @@ function encodeJson(value) {
39
39
  return JSON.stringify(value);
40
40
  }
41
41
 
42
+ //#endregion
43
+ //#region src/ai-images.ts
44
+ /**
45
+ * Normalize an image input into a content block, applying the SAME validity
46
+ * rule the backend's gateway + the MCP tool use: trim `url`/`data`, treat a
47
+ * blank/whitespace-only value as absent, and return `null` when NEITHER a real
48
+ * url nor real data survives. A `null` block is dropped by the caller so we
49
+ * never emit an empty image — keeping the SDK, MCP, and gateway in agreement.
50
+ */
51
+ function normalizeImageBlock(img) {
52
+ const url = typeof img.url === "string" && img.url.trim().length > 0 ? img.url.trim() : void 0;
53
+ const data = typeof img.data === "string" && img.data.trim().length > 0 ? img.data.trim() : void 0;
54
+ if (!url && !data) return null;
55
+ const block = { type: "image" };
56
+ if (url !== void 0) block.url = url;
57
+ if (data !== void 0) block.data = data;
58
+ if (img.mime !== void 0) block.mime = img.mime;
59
+ return block;
60
+ }
61
+ /**
62
+ * Expand a request's convenience `images` into content blocks on `messages`.
63
+ * Returns the SAME object reference when there's nothing to expand, or a new
64
+ * request (with `images` removed) when images were attached.
65
+ */
66
+ function expandAiImages(request) {
67
+ const images = request.images;
68
+ if (!images || images.length === 0) return request;
69
+ const imageBlocks = images.map(normalizeImageBlock).filter((b) => b !== null);
70
+ const { images: _images,...rest } = request;
71
+ if (imageBlocks.length === 0) return rest;
72
+ const messages = (rest.messages ?? []).map((m) => ({ ...m }));
73
+ let lastUserIdx = -1;
74
+ for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") {
75
+ lastUserIdx = i;
76
+ break;
77
+ }
78
+ if (lastUserIdx === -1) {
79
+ const variableBlocks = rest.variables && Object.keys(rest.variables).length > 0 ? [{
80
+ type: "text",
81
+ text: JSON.stringify(rest.variables)
82
+ }] : [];
83
+ messages.push({
84
+ role: "user",
85
+ content: [...variableBlocks, ...imageBlocks]
86
+ });
87
+ } else {
88
+ const existing = messages[lastUserIdx].content;
89
+ let leadingBlocks;
90
+ if (typeof existing === "string") leadingBlocks = [{
91
+ type: "text",
92
+ text: existing
93
+ }];
94
+ else if (Array.isArray(existing)) leadingBlocks = existing;
95
+ else if (existing === null || existing === void 0) leadingBlocks = [];
96
+ else leadingBlocks = [{
97
+ type: "text",
98
+ text: JSON.stringify(existing)
99
+ }];
100
+ messages[lastUserIdx] = {
101
+ ...messages[lastUserIdx],
102
+ content: [...leadingBlocks, ...imageBlocks]
103
+ };
104
+ }
105
+ return {
106
+ ...rest,
107
+ messages
108
+ };
109
+ }
110
+
42
111
  //#endregion
43
112
  //#region src/error.ts
44
113
  var AmbaApiError = class extends Error {
45
114
  code;
46
115
  details;
116
+ /**
117
+ * Server-side request / trace id (the `X-Amba-Request-Id` response
118
+ * header, falling back to the `request_id` field in the standard error
119
+ * envelope). Present on errors thrown by `Amba.functions.invoke()` so a
120
+ * failed run can be correlated with server logs
121
+ * (`functions logs --request-id <traceId>`). `undefined` when the failure
122
+ * happened before a response was received (e.g. a transport error).
123
+ */
124
+ traceId;
47
125
  constructor(code, message, details) {
48
126
  super(message);
49
127
  this.name = "AmbaApiError";
@@ -254,6 +332,978 @@ var ConversationSubscription = class {
254
332
  });
255
333
  }
256
334
  };
335
+ /**
336
+ * Builder returned by `Amba.gamification`. Call `.subscribe(cb)` to receive the
337
+ * signed-in user's xp / level / achievement / streak changes live instead of
338
+ * polling.
339
+ *
340
+ * const unsub = Amba.gamification.subscribe((change) => {
341
+ * // change.kind: "xp" | "level" | "achievement" | "streak"
342
+ * // change.data: the new state
343
+ * });
344
+ * unsub(); // stop listening
345
+ *
346
+ * Delivery is scoped to the signed-in user — you only ever receive your own
347
+ * gamification changes, never another user's. Reconnects with the same
348
+ * exponential backoff as collection subscriptions.
349
+ */
350
+ var GamificationSubscription = class {
351
+ constructor(ctx) {
352
+ this.ctx = ctx;
353
+ }
354
+ subscribe(cb, options = {}) {
355
+ if (!this.ctx.authHeaders()) throw new Error("Amba.gamification.subscribe() requires an authenticated session — call Amba.auth.signIn*/verify* first.");
356
+ return openGamificationStream({
357
+ url: `${this.ctx.baseUrl}/v1/client/realtime/gamification`,
358
+ authHeaders: this.ctx.authHeaders,
359
+ onChange: cb,
360
+ options
361
+ });
362
+ }
363
+ };
364
+ /**
365
+ * Open a streamed GET to the gamification realtime endpoint. Identical
366
+ * transport + backoff/reconnect to {@link openStream}; only the frame payload
367
+ * shape differs (gamification carries `kind`, not `op`/`row`), so it gets its
368
+ * own thin consumer rather than overloading the row-change parser.
369
+ */
370
+ function openGamificationStream(args) {
371
+ let stopped = false;
372
+ let controller = null;
373
+ let backoff = INITIAL_BACKOFF_MS;
374
+ const run = async () => {
375
+ while (!stopped) {
376
+ controller = new AbortController();
377
+ try {
378
+ const headers = args.authHeaders();
379
+ if (!headers) throw new Error("no active session");
380
+ const resp = await fetch(args.url, {
381
+ method: "GET",
382
+ headers: {
383
+ ...headers,
384
+ Accept: "text/event-stream"
385
+ },
386
+ signal: controller.signal
387
+ });
388
+ if (!resp.ok || !resp.body) throw new Error(`realtime connect failed: HTTP ${resp.status}`);
389
+ await consumeGamification(resp.body, args, () => {
390
+ backoff = INITIAL_BACKOFF_MS;
391
+ args.options.onOpen?.();
392
+ });
393
+ } catch (err) {
394
+ if (stopped) break;
395
+ args.options.onError?.(err);
396
+ }
397
+ if (stopped) break;
398
+ await delay(backoff);
399
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
400
+ }
401
+ };
402
+ run();
403
+ return () => {
404
+ stopped = true;
405
+ controller?.abort();
406
+ };
407
+ }
408
+ async function consumeGamification(body, args, onFirstFrame) {
409
+ const reader = body.getReader();
410
+ const decoder = new TextDecoder();
411
+ let buffer = "";
412
+ let sawFrame = false;
413
+ for (;;) {
414
+ const { done, value } = await reader.read();
415
+ if (done) return;
416
+ buffer += decoder.decode(value, { stream: true });
417
+ let idx;
418
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
419
+ const frame = buffer.slice(0, idx);
420
+ buffer = buffer.slice(idx + 2);
421
+ if (!sawFrame) {
422
+ sawFrame = true;
423
+ onFirstFrame();
424
+ }
425
+ dispatchGamificationFrame(frame, args.onChange);
426
+ }
427
+ }
428
+ }
429
+ function dispatchGamificationFrame(frame, onChange) {
430
+ let event = "";
431
+ const dataLines = [];
432
+ for (const line of frame.split("\n")) {
433
+ if (line.startsWith(":")) continue;
434
+ if (line.startsWith("event:")) event = line.slice(6).trim();
435
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
436
+ }
437
+ if (event !== "change") return;
438
+ try {
439
+ const payload = JSON.parse(dataLines.join("\n"));
440
+ onChange({
441
+ kind: payload.kind,
442
+ data: payload.data,
443
+ truncated: payload.truncated
444
+ });
445
+ } catch {}
446
+ }
447
+
448
+ //#endregion
449
+ //#region src/ai-stream.ts
450
+ function newUsageAccumulator() {
451
+ return {
452
+ input_tokens: 0,
453
+ output_tokens: 0,
454
+ cache_creation_input_tokens: 0,
455
+ cache_read_input_tokens: 0
456
+ };
457
+ }
458
+ function toAiUsage(acc) {
459
+ return {
460
+ input_tokens: acc.input_tokens,
461
+ output_tokens: acc.output_tokens,
462
+ cache_creation_input_tokens: acc.cache_creation_input_tokens,
463
+ cache_read_input_tokens: acc.cache_read_input_tokens
464
+ };
465
+ }
466
+ /** Read a numeric field defensively (vendors occasionally send null). */
467
+ function num(v) {
468
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
469
+ }
470
+ /**
471
+ * Merge a vendor `usage`-shaped object into the accumulator. Handles both
472
+ * Anthropic (`input_tokens` / `output_tokens` / `cache_*`) and OpenAI
473
+ * (`prompt_tokens` / `completion_tokens`) field names, taking the max so a
474
+ * later frame that re-reports a counter doesn't shrink it.
475
+ */
476
+ function mergeUsage(acc, usage) {
477
+ const input = usage["input_tokens"] ?? usage["prompt_tokens"];
478
+ const output = usage["output_tokens"] ?? usage["completion_tokens"];
479
+ if (input !== void 0) acc.input_tokens = Math.max(acc.input_tokens, num(input));
480
+ if (output !== void 0) acc.output_tokens = Math.max(acc.output_tokens, num(output));
481
+ if (usage["cache_creation_input_tokens"] !== void 0) acc.cache_creation_input_tokens = Math.max(acc.cache_creation_input_tokens, num(usage["cache_creation_input_tokens"]));
482
+ if (usage["cache_read_input_tokens"] !== void 0) acc.cache_read_input_tokens = Math.max(acc.cache_read_input_tokens, num(usage["cache_read_input_tokens"]));
483
+ }
484
+ /**
485
+ * Parse one SSE frame (everything between blank lines) and return the text
486
+ * delta it carries, if any. Side-effect: folds any usage / model / stop
487
+ * metadata into `acc`. Returns `null` for keepalive comments, the `[DONE]`
488
+ * sentinel, and frames with no text (e.g. Anthropic's `message_start`).
489
+ *
490
+ * Tolerant by design — a malformed `data:` payload is skipped rather than
491
+ * tearing down the stream.
492
+ */
493
+ function parseAiSseFrame(frame, acc) {
494
+ const dataLines = [];
495
+ for (const line of frame.split(/\r\n|\r|\n/)) {
496
+ if (line.startsWith(":")) continue;
497
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
498
+ }
499
+ if (dataLines.length === 0) return null;
500
+ const data = dataLines.join("\n").trim();
501
+ if (data === "" || data === "[DONE]") return null;
502
+ let payload;
503
+ try {
504
+ payload = JSON.parse(data);
505
+ } catch {
506
+ return null;
507
+ }
508
+ const type = typeof payload["type"] === "string" ? payload["type"] : "";
509
+ if (type === "content_block_delta") {
510
+ const text = payload["delta"]?.["text"];
511
+ return typeof text === "string" ? text : null;
512
+ }
513
+ if (type === "message_start") {
514
+ const message = payload["message"];
515
+ const usage = message?.["usage"];
516
+ if (usage && typeof usage === "object") mergeUsage(acc, usage);
517
+ if (typeof message?.["model"] === "string") acc.model = message["model"];
518
+ return null;
519
+ }
520
+ if (type === "message_delta") {
521
+ const usage = payload["usage"];
522
+ if (usage && typeof usage === "object") mergeUsage(acc, usage);
523
+ const delta = payload["delta"];
524
+ if (typeof delta?.["stop_reason"] === "string") acc.stopReason = delta["stop_reason"];
525
+ return null;
526
+ }
527
+ if (type === "message_stop") return null;
528
+ if (Array.isArray(payload["choices"])) {
529
+ const choices = payload["choices"];
530
+ if (typeof payload["model"] === "string") acc.model = payload["model"];
531
+ const usage = payload["usage"];
532
+ if (usage && typeof usage === "object") mergeUsage(acc, usage);
533
+ const first = choices[0];
534
+ if (first) {
535
+ const finish = first["finish_reason"];
536
+ if (typeof finish === "string") acc.stopReason = finish;
537
+ const content = first["delta"]?.["content"];
538
+ if (typeof content === "string" && content.length > 0) return content;
539
+ }
540
+ return null;
541
+ }
542
+ return null;
543
+ }
544
+ /**
545
+ * SSE event-frame delimiter: a blank line. The spec permits any line ending
546
+ * (`\r\n`, `\n`, `\r`), so a blank line is two consecutive line breaks in any
547
+ * combination — `\r\n\r\n`, `\n\n`, or `\r\r`. Some servers / proxies emit
548
+ * CRLF, so splitting on `\n\n` alone would never frame those streams.
549
+ */
550
+ const SSE_FRAME_DELIMITER = /\r\n\r\n|\n\n|\r\r/;
551
+ /**
552
+ * Pull the next complete SSE frame off the front of `buffer`, returning the
553
+ * frame text (without the trailing blank-line delimiter) and the remaining
554
+ * buffer, or `null` when no complete frame is present yet.
555
+ *
556
+ * Only *complete* delimiters match: a buffer ending mid-delimiter (e.g.
557
+ * `…\r\n\r`, awaiting the final `\n`) leaves the partial bytes buffered for the
558
+ * next read rather than framing prematurely.
559
+ */
560
+ function splitNextSseFrame(buffer) {
561
+ const m = SSE_FRAME_DELIMITER.exec(buffer);
562
+ if (!m) return null;
563
+ return {
564
+ frame: buffer.slice(0, m.index),
565
+ rest: buffer.slice(m.index + m[0].length)
566
+ };
567
+ }
568
+ /**
569
+ * Open the streaming route and yield vendor-neutral events. Throws an
570
+ * `AmbaApiError` on a transport failure or a non-2xx response (the error body
571
+ * is read and surfaced just like the buffered path).
572
+ *
573
+ * The generator yields zero or more `{type:'delta'}` events followed by
574
+ * exactly one terminal `{type:'done'}` when the stream completes cleanly.
575
+ */
576
+ async function* invokeAiStream(args) {
577
+ let resp;
578
+ try {
579
+ resp = await fetch(args.url, {
580
+ method: "POST",
581
+ headers: args.headers,
582
+ body: args.body
583
+ });
584
+ } catch (err) {
585
+ throw new AmbaApiError("NETWORK_ERROR", `AI stream network error: ${String(err)}`);
586
+ }
587
+ if (!resp.ok) {
588
+ const text = await resp.text().catch(() => "");
589
+ let parsed = void 0;
590
+ if (text.length > 0) try {
591
+ parsed = JSON.parse(text);
592
+ } catch {
593
+ parsed = text;
594
+ }
595
+ const envelope = parsed && typeof parsed === "object" ? parsed : void 0;
596
+ throw new AmbaApiError(typeof envelope?.error?.code === "string" ? envelope.error.code : `HTTP_${resp.status}`, typeof envelope?.error?.message === "string" ? envelope.error.message : typeof parsed === "string" && parsed.length > 0 ? parsed : `AI stream failed with status ${resp.status}`, parsed);
597
+ }
598
+ if (!resp.body) throw new AmbaApiError("NETWORK_ERROR", "AI stream response had no readable body (the environment does not expose a streaming fetch body).");
599
+ const reader = resp.body.getReader();
600
+ const decoder = new TextDecoder();
601
+ const acc = newUsageAccumulator();
602
+ let buffer = "";
603
+ try {
604
+ for (;;) {
605
+ const { done, value } = await reader.read();
606
+ if (value) buffer += decoder.decode(value, { stream: true });
607
+ if (done) buffer += decoder.decode();
608
+ let split;
609
+ while ((split = splitNextSseFrame(buffer)) !== null) {
610
+ buffer = split.rest;
611
+ const text = parseAiSseFrame(split.frame, acc);
612
+ if (text !== null && text.length > 0) {
613
+ args.onChunk?.(text);
614
+ yield {
615
+ type: "delta",
616
+ text
617
+ };
618
+ }
619
+ }
620
+ if (done) {
621
+ const tail = buffer.trim();
622
+ if (tail.length > 0) {
623
+ const text = parseAiSseFrame(tail, acc);
624
+ if (text !== null && text.length > 0) {
625
+ args.onChunk?.(text);
626
+ yield {
627
+ type: "delta",
628
+ text
629
+ };
630
+ }
631
+ }
632
+ break;
633
+ }
634
+ }
635
+ } finally {
636
+ try {
637
+ reader.releaseLock();
638
+ } catch {}
639
+ }
640
+ yield {
641
+ type: "done",
642
+ usage: toAiUsage(acc),
643
+ model: acc.model,
644
+ stopReason: acc.stopReason ?? null
645
+ };
646
+ }
647
+
648
+ //#endregion
649
+ //#region src/resilience.ts
650
+ /**
651
+ * Best-effort online probe. In a browser/RN runtime `navigator.onLine`
652
+ * exists; under Node / SSR it doesn't, so we treat "unknown" as online
653
+ * (the executor's own error handling then decides).
654
+ */
655
+ function isOnline() {
656
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
657
+ if (nav && typeof nav.onLine === "boolean") return nav.onLine;
658
+ return true;
659
+ }
660
+ /**
661
+ * Error codes the server raised deterministically — replaying them would fail
662
+ * identically, so they're NEVER queued even when the device reports offline.
663
+ * (A request can fail validation/auth/etc. while `navigator.onLine` is briefly
664
+ * false; we must not buffer-and-retry those forever.)
665
+ */
666
+ const DETERMINISTIC_ERROR_CODES = new Set([
667
+ "VALIDATION_ERROR",
668
+ "INVALID_ARGUMENT",
669
+ "INVALID_INPUT",
670
+ "UNAUTHORIZED",
671
+ "FORBIDDEN",
672
+ "NOT_FOUND",
673
+ "CONFLICT",
674
+ "CONSENT_NOT_GRANTED",
675
+ "NOT_INITIALIZED"
676
+ ]);
677
+ /**
678
+ * Is this a deterministic server rejection — a coded error the server would
679
+ * raise identically on every replay (bad input, auth, not-found, conflict)? A
680
+ * queued op that fails this way is poison: retrying it forever would wedge the
681
+ * queue, so the flush DROPS it. Everything else (transport failures, 5xx
682
+ * `HTTP_ERROR`, `RATE_LIMITED`, `CIRCUIT_OPEN`, anything uncoded) is treated as
683
+ * transient and RETAINED for a later attempt — favoring retain over data loss.
684
+ */
685
+ function isDeterministicError(err) {
686
+ const code = err?.code;
687
+ return typeof code === "string" && DETERMINISTIC_ERROR_CODES.has(code);
688
+ }
689
+ /**
690
+ * Heuristic: does a thrown value look like a transient offline / transport
691
+ * failure (vs a deterministic 4xx the server would reject again)? Used at
692
+ * ENQUEUE time to decide whether a failed write is worth buffering — a
693
+ * `VALIDATION_ERROR` will fail identically on replay, so it throws instead.
694
+ *
695
+ * A deterministic server error is excluded even when the device reports
696
+ * offline: the request clearly reached the server (it got a coded response),
697
+ * so retrying it on reconnect is pointless and would wedge the queue.
698
+ *
699
+ * NOTE: this is intentionally stricter than {@link isDeterministicError}'s
700
+ * inverse — at enqueue time we only buffer GENUINE connectivity failures (a
701
+ * non-offline 5xx/429 surfaces to the caller immediately rather than silently
702
+ * queueing). The flush drop-decision uses {@link isDeterministicError} so a
703
+ * transient 5xx/429 encountered DURING replay is retained, not dropped.
704
+ */
705
+ function isOfflineError(err) {
706
+ const code = err?.code;
707
+ if (typeof code === "string" && DETERMINISTIC_ERROR_CODES.has(code)) return false;
708
+ if (code === "NETWORK_ERROR") return true;
709
+ const msg = err?.message;
710
+ if (typeof msg === "string" && /network (error|request failed)|failed to fetch|fetch failed|load failed/i.test(msg)) return true;
711
+ return !isOnline();
712
+ }
713
+ /** Generate a stable idempotency key for a queued op. */
714
+ function newIdempotencyKey() {
715
+ const c = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
716
+ if (c && typeof c.randomUUID === "function") return `amba_obx_${c.randomUUID()}`;
717
+ return `amba_obx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
718
+ }
719
+ /**
720
+ * Durable, ordered, idempotent offline outbox. Opt-in via `Amba.offline`.
721
+ * Single in-flight flush is enforced so concurrent reconnect signals don't
722
+ * double-replay.
723
+ */
724
+ var OfflineQueue = class {
725
+ store;
726
+ executor;
727
+ storageKey;
728
+ /**
729
+ * Notified when a queued op is dropped because its replay failed
730
+ * deterministically (a poisoned op the server will always reject). Lets the
731
+ * SDK surface the loss (debug log) instead of silently discarding.
732
+ */
733
+ onDropped;
734
+ flushing = null;
735
+ /**
736
+ * Serializes every read-modify-write of the persisted outbox. Each mutation
737
+ * chains onto the previous one's promise, so concurrent `enqueue`s — and an
738
+ * `enqueue` racing the per-op removal inside `flush` — can never read a stale
739
+ * snapshot and clobber each other's writes (the lost-update bug). All
740
+ * storage mutations funnel through `mutate()`; only `pendingCount`/`pending`
741
+ * read without taking the lock (a stale-by-one read is harmless).
742
+ */
743
+ tail = Promise.resolve();
744
+ /** Detach function for the auto-flush `online` listener, if installed. */
745
+ detachOnline = null;
746
+ /**
747
+ * Set once `dispose()` is called (on teardown / reconfigure). A disposed
748
+ * queue refuses to mutate the persisted outbox, so an orphaned in-flight
749
+ * flush — or any late call against the old instance — can never race the new
750
+ * queue by reading/writing the same storage after it's been replaced.
751
+ */
752
+ disposed = false;
753
+ constructor(opts) {
754
+ this.store = opts.store;
755
+ this.executor = opts.executor;
756
+ this.storageKey = opts.storageKey ?? "offline_outbox";
757
+ this.onDropped = opts.onDropped;
758
+ }
759
+ /**
760
+ * Run `fn` exclusively against the persisted outbox: it chains after every
761
+ * prior mutation and the next one waits for it. `fn` receives the current
762
+ * ops and returns the next ops to persist plus its own result.
763
+ */
764
+ mutate(fn, opts) {
765
+ const run = this.tail.then(async () => {
766
+ if (this.disposed && opts?.rejectIfDisposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
767
+ const { next, result } = await fn(await this.load());
768
+ await this.save(next);
769
+ return result;
770
+ });
771
+ this.tail = run.catch(() => void 0);
772
+ return run;
773
+ }
774
+ async load() {
775
+ const raw = await this.store.get(this.storageKey);
776
+ if (!raw) return [];
777
+ try {
778
+ const parsed = JSON.parse(raw);
779
+ return Array.isArray(parsed) ? parsed : [];
780
+ } catch {
781
+ return [];
782
+ }
783
+ }
784
+ async save(ops) {
785
+ if (this.disposed) return;
786
+ if (ops.length === 0) {
787
+ await this.store.delete(this.storageKey);
788
+ return;
789
+ }
790
+ await this.store.set(this.storageKey, JSON.stringify(ops));
791
+ }
792
+ /**
793
+ * Permanently retire this queue. Detaches the auto-flush listener and marks
794
+ * it disposed so it can no longer mutate the persisted outbox. Returns once
795
+ * any in-flight flush + pending mutation chain has settled, so a caller (e.g.
796
+ * `configure()` teardown) can safely create a replacement queue knowing the
797
+ * old one will never touch storage again. Idempotent.
798
+ */
799
+ async dispose() {
800
+ this.disposed = true;
801
+ if (this.detachOnline) {
802
+ this.detachOnline();
803
+ this.detachOnline = null;
804
+ }
805
+ try {
806
+ await this.flushing;
807
+ } catch {}
808
+ await this.tail.catch(() => void 0);
809
+ }
810
+ /** Whether this queue has been disposed. */
811
+ get isDisposed() {
812
+ return this.disposed;
813
+ }
814
+ /** Append an op to the durable outbox. Returns the persisted op. */
815
+ async enqueue(op) {
816
+ if (this.disposed) throw new Error("amba offline queue was disposed (SDK reconfigured)");
817
+ const full = {
818
+ ...op,
819
+ idempotencyKey: op.idempotencyKey ?? newIdempotencyKey(),
820
+ enqueuedAt: (/* @__PURE__ */ new Date()).toISOString()
821
+ };
822
+ return this.mutate((ops) => ({
823
+ next: [...ops, full],
824
+ result: full
825
+ }), { rejectIfDisposed: true });
826
+ }
827
+ /** Number of ops awaiting replay. */
828
+ async pendingCount() {
829
+ return (await this.load()).length;
830
+ }
831
+ /** Snapshot the pending ops (a copy — mutating it does not affect the queue). */
832
+ async pending() {
833
+ return [...await this.load()];
834
+ }
835
+ /** Drop every queued op without replaying. */
836
+ async clear() {
837
+ await this.mutate(() => ({
838
+ next: [],
839
+ result: void 0
840
+ }));
841
+ }
842
+ /**
843
+ * Replay the outbox in FIFO order. Each op is removed only after it
844
+ * succeeds; the first failure stops the flush and retains the remainder
845
+ * (so order is preserved and nothing is dropped mid-batch). Concurrent
846
+ * calls share one in-flight flush.
847
+ */
848
+ async flush() {
849
+ if (this.disposed) return {
850
+ flushed: 0,
851
+ dropped: 0,
852
+ remaining: 0
853
+ };
854
+ if (this.flushing) return this.flushing;
855
+ this.flushing = this.runFlush().finally(() => {
856
+ this.flushing = null;
857
+ });
858
+ return this.flushing;
859
+ }
860
+ async runFlush() {
861
+ let flushed = 0;
862
+ let dropped = 0;
863
+ for (;;) {
864
+ if (this.disposed) break;
865
+ const head = (await this.load())[0];
866
+ if (!head) break;
867
+ let replayError = void 0;
868
+ let ok = false;
869
+ try {
870
+ await this.executor(head);
871
+ ok = true;
872
+ } catch (err) {
873
+ replayError = err;
874
+ }
875
+ if (!ok && !isDeterministicError(replayError)) {
876
+ await this.mutate((ops) => {
877
+ return {
878
+ next: ops.some((o) => o.idempotencyKey === head.idempotencyKey) ? ops : [head, ...ops],
879
+ result: void 0
880
+ };
881
+ });
882
+ break;
883
+ }
884
+ if (!await this.mutate((ops) => {
885
+ const idx = ops.findIndex((o) => o.idempotencyKey === head.idempotencyKey);
886
+ if (idx === -1) return {
887
+ next: ops,
888
+ result: false
889
+ };
890
+ return {
891
+ next: ops.slice(0, idx).concat(ops.slice(idx + 1)),
892
+ result: true
893
+ };
894
+ })) break;
895
+ if (ok) flushed += 1;
896
+ else {
897
+ dropped += 1;
898
+ try {
899
+ this.onDropped?.(head, replayError);
900
+ } catch {}
901
+ }
902
+ }
903
+ const remaining = (await this.load()).length;
904
+ return {
905
+ flushed,
906
+ dropped,
907
+ remaining
908
+ };
909
+ }
910
+ /**
911
+ * Install an auto-flush listener on the runtime's `online` event so the
912
+ * queue drains the moment connectivity returns. Idempotent — a second
913
+ * call is a no-op. Returns a detach function.
914
+ */
915
+ startAutoFlush() {
916
+ if (this.detachOnline) return this.detachOnline;
917
+ const target = typeof globalThis !== "undefined" && typeof globalThis.addEventListener === "function" ? globalThis : null;
918
+ if (!target) {
919
+ this.detachOnline = () => {};
920
+ return this.detachOnline;
921
+ }
922
+ const handler = () => {
923
+ this.flush();
924
+ };
925
+ target.addEventListener("online", handler);
926
+ this.detachOnline = () => {
927
+ target.removeEventListener("online", handler);
928
+ this.detachOnline = null;
929
+ };
930
+ return this.detachOnline;
931
+ }
932
+ };
933
+ /**
934
+ * Apply a local change immediately, fire the real mutation, and roll the
935
+ * local change back if the mutation fails. Framework-agnostic: `apply` and
936
+ * `rollback` operate on whatever local view you hand them (a React state
937
+ * setter, a store, an in-memory cache).
938
+ *
939
+ * Resolves with the mutation's result on success. On failure it invokes
940
+ * `rollback` (with the value `apply` returned, so you can restore a captured
941
+ * snapshot) and rethrows the original error.
942
+ *
943
+ * @example
944
+ * await withOptimistic({
945
+ * apply: () => { const prev = todos; setTodos([...todos, draft]); return prev; },
946
+ * mutate: () => Amba.collections.insert("todos", draft),
947
+ * rollback: (prev) => setTodos(prev),
948
+ * });
949
+ */
950
+ async function withOptimistic(spec) {
951
+ const applied = spec.apply();
952
+ try {
953
+ return await spec.mutate();
954
+ } catch (err) {
955
+ try {
956
+ spec.rollback(applied);
957
+ } catch {}
958
+ throw err;
959
+ }
960
+ }
961
+
962
+ //#endregion
963
+ //#region src/push-rotation.ts
964
+ /**
965
+ * Subscribe to browser push-subscription rotation. On a
966
+ * `pushsubscriptionchange`, the new subscription endpoint is forwarded to
967
+ * `reRegister` (which the SDK points at its idempotent token-upsert).
968
+ *
969
+ * Returns an unsubscribe function. No-ops (returning a no-op detacher) when
970
+ * the runtime has no service-worker / push support — e.g. SSR or a browser
971
+ * without Web Push — so callers can invoke it unconditionally.
972
+ */
973
+ /**
974
+ * Are we executing inside a ServiceWorkerGlobalScope? `pushsubscriptionchange`
975
+ * ONLY fires there — on a normal page `self === window`, where the event never
976
+ * fires, so attaching a listener silently does nothing. We detect the scope by
977
+ * the `ServiceWorkerGlobalScope` constructor when present, falling back to the
978
+ * SW-only `self.registration.pushManager` shape (a window has no `registration`
979
+ * on its global).
980
+ */
981
+ function inServiceWorkerScope(g) {
982
+ if (typeof g !== "object" || g === null) return false;
983
+ const swCtor = globalThis.ServiceWorkerGlobalScope;
984
+ if (typeof swCtor === "function" && g instanceof swCtor) return true;
985
+ const reg = g.registration;
986
+ return !!reg && typeof reg.pushManager === "object" && reg.pushManager !== null;
987
+ }
988
+ let pageScopeWarned = false;
989
+ function enableWebPushAutoRotation(reRegister) {
990
+ const g = typeof self !== "undefined" ? self : void 0;
991
+ if (!g || !inServiceWorkerScope(g)) {
992
+ if (!pageScopeWarned && typeof console !== "undefined") {
993
+ pageScopeWarned = true;
994
+ console.warn("[amba] push.enableAutoRotation() must run inside your service worker — `pushsubscriptionchange` doesn't fire on a page/window. Call it from your amba service worker. No-op here.");
995
+ }
996
+ return () => {};
997
+ }
998
+ const sw = g;
999
+ if (typeof sw.addEventListener !== "function") return () => {};
1000
+ const onChange = (event) => {
1001
+ (async () => {
1002
+ try {
1003
+ const ev = event;
1004
+ let endpoint = ev?.newSubscription?.endpoint;
1005
+ if (!endpoint && sw.registration?.pushManager?.getSubscription) endpoint = (await sw.registration.pushManager.getSubscription())?.endpoint ?? void 0;
1006
+ if (!endpoint) return;
1007
+ const work = reRegister(endpoint, "web");
1008
+ if (typeof ev?.waitUntil === "function") ev.waitUntil(work);
1009
+ await work;
1010
+ } catch (err) {
1011
+ if (typeof console !== "undefined") console.error("[amba] push auto-rotation re-register failed", err);
1012
+ }
1013
+ })();
1014
+ };
1015
+ sw.addEventListener("pushsubscriptionchange", onChange);
1016
+ return () => {
1017
+ sw.removeEventListener?.("pushsubscriptionchange", onChange);
1018
+ };
1019
+ }
1020
+
1021
+ //#endregion
1022
+ //#region src/local-notifications.ts
1023
+ /**
1024
+ * Browsers store a `setTimeout` delay in a signed 32-bit int. A delay
1025
+ * larger than `2^31 - 1` ms (~24.8 days) overflows and is clamped — most
1026
+ * browsers fire (often immediately) rather than wait. So a far-future
1027
+ * local notification scheduled via the in-page timer fallback would fire
1028
+ * at the wrong time. We chunk long delays: arm a timer for at most
1029
+ * `MAX_TIMEOUT_MS`, and on wake re-arm for the remainder until due.
1030
+ */
1031
+ const MAX_TIMEOUT_MS = 2147483647;
1032
+ const pendingTimers = /* @__PURE__ */ new Map();
1033
+ function hasNotificationApi() {
1034
+ return typeof globalThis !== "undefined" && typeof globalThis.Notification === "function";
1035
+ }
1036
+ function timestampTriggerCtor() {
1037
+ const ctor = globalThis.TimestampTrigger;
1038
+ return typeof ctor === "function" ? ctor : void 0;
1039
+ }
1040
+ function genId() {
1041
+ const c = globalThis.crypto;
1042
+ if (c?.randomUUID) return c.randomUUID();
1043
+ return `amba_local_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1044
+ }
1045
+ /**
1046
+ * Per-INSTANCE token. The stable `__ambaLocalId` survives a reschedule of the
1047
+ * same id; this token does not — every individual `scheduleLocal` call mints a
1048
+ * fresh one. It lets `listScheduledLocal` distinguish a NEW pending schedule
1049
+ * for an id from an OLD already-fired-but-still-visible notification of the
1050
+ * SAME id (which the Service Worker reports under `getNotifications()`),
1051
+ * instead of conflating the two by id and hiding the pending one. (Round-5.)
1052
+ */
1053
+ function genInstanceToken() {
1054
+ return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
1055
+ }
1056
+ /**
1057
+ * Resolve the id a schedule will be filed under. A caller may omit `id`
1058
+ * (we mint one) but must not pass a BLANK/whitespace id: `cancelLocal`
1059
+ * rejects blank ids, so returning one in the handle would make the
1060
+ * notification uncancellable. We reject blank at schedule time so every
1061
+ * returned handle id round-trips through `cancelLocal`. Shared by both
1062
+ * SDK platforms (web + react-native) for identical validation.
1063
+ */
1064
+ function normalizeScheduleId(rawId) {
1065
+ if (rawId === void 0) return genId();
1066
+ if (typeof rawId !== "string" || rawId.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: id, when provided, must be a non-empty string");
1067
+ return rawId;
1068
+ }
1069
+ function parseTrigger(input) {
1070
+ const trigger = input.trigger;
1071
+ if (!trigger || typeof trigger !== "object") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger must be { at: Date } or { inSeconds: number }");
1072
+ if ("at" in trigger) {
1073
+ const ms = (trigger.at instanceof Date ? trigger.at : new Date(trigger.at)).getTime();
1074
+ if (Number.isNaN(ms)) throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger.at must be a valid Date");
1075
+ return {
1076
+ kind: "absolute",
1077
+ at: ms
1078
+ };
1079
+ }
1080
+ if ("inSeconds" in trigger) {
1081
+ if (!Number.isFinite(trigger.inSeconds) || trigger.inSeconds < 0) throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger.inSeconds must be a non-negative number");
1082
+ return {
1083
+ kind: "relative",
1084
+ seconds: trigger.inSeconds
1085
+ };
1086
+ }
1087
+ throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: trigger must be { at: Date } or { inSeconds: number }");
1088
+ }
1089
+ /**
1090
+ * Resolve a parsed trigger to an absolute fire time (ms epoch). For a
1091
+ * relative trigger this is `now + seconds` — call it ONLY after permission
1092
+ * has resolved so `inSeconds` counts from when scheduling actually completes.
1093
+ */
1094
+ function computeFireAt(parsed) {
1095
+ return parsed.kind === "absolute" ? parsed.at : Date.now() + parsed.seconds * 1e3;
1096
+ }
1097
+ function validateContent(input) {
1098
+ if (!input || typeof input.title !== "string" || input.title.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: title is required");
1099
+ if (typeof input.body !== "string") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.scheduleLocal: body must be a string");
1100
+ }
1101
+ async function ensurePermission() {
1102
+ const N = globalThis.Notification;
1103
+ let permission = N.permission;
1104
+ if (permission === "default") permission = await N.requestPermission();
1105
+ if (permission !== "granted") throw new AmbaApiError("LOCAL_NOTIFICATIONS_PERMISSION_DENIED", "user denied notification permission — local notifications cannot be shown");
1106
+ }
1107
+ /**
1108
+ * How long we'll wait for an existing-but-not-yet-active registration to
1109
+ * become `ready` before falling through to the in-page timer path. A real
1110
+ * activation is near-instant; this cap only guards against a wedged
1111
+ * activation hanging the call.
1112
+ */
1113
+ const SW_READY_TIMEOUT_MS = 1e3;
1114
+ /**
1115
+ * Resolve the Service Worker registration to use for OS-trigger
1116
+ * notifications — WITHOUT ever hanging.
1117
+ *
1118
+ * The footgun this avoids: `navigator.serviceWorker.ready` NEVER resolves
1119
+ * when no service worker is registered (the common case for a page that has
1120
+ * the Notifications API but no SW). Awaiting it unconditionally would hang
1121
+ * `scheduleLocal` / `cancelLocal` / `listScheduledLocal` forever instead of
1122
+ * falling back to the documented in-page timer path. (Round-6 HIGH.)
1123
+ *
1124
+ * Instead we first call `getRegistration()`, which resolves to `undefined`
1125
+ * when nothing is registered (no hang). Only if a registration exists do we
1126
+ * wait — briefly, raced against a timeout — for `.ready`, so an active SW is
1127
+ * used while a wedged activation can't stall the caller.
1128
+ */
1129
+ async function getServiceWorkerRegistration() {
1130
+ const nav = globalThis.navigator;
1131
+ if (!nav || !("serviceWorker" in nav)) return void 0;
1132
+ const swc = nav.serviceWorker;
1133
+ let existing;
1134
+ try {
1135
+ existing = await swc.getRegistration();
1136
+ } catch {
1137
+ return;
1138
+ }
1139
+ if (!existing) return void 0;
1140
+ try {
1141
+ return await Promise.race([swc.ready, new Promise((resolve) => setTimeout(() => resolve(void 0), SW_READY_TIMEOUT_MS))]) ?? existing;
1142
+ } catch {
1143
+ return existing;
1144
+ }
1145
+ }
1146
+ /**
1147
+ * Cancel (close) any Service Worker OS-trigger notification scheduled by us
1148
+ * under `tag === id`. Passes `includeTriggered: true` so that
1149
+ * scheduled-but-not-yet-fired trigger notifications are returned by
1150
+ * `getNotifications` — without it, pending OS-trigger notifications are
1151
+ * invisible and therefore impossible to cancel. No-op when there's no
1152
+ * registration (degraded / timer-only environments).
1153
+ */
1154
+ async function cancelOsTriggerNotification(registration, id) {
1155
+ if (!registration) return;
1156
+ try {
1157
+ const matches = await registration.getNotifications({
1158
+ tag: id,
1159
+ includeTriggered: true
1160
+ });
1161
+ for (const n of matches) {
1162
+ if ((n.data ?? {}).__ambaLocalId !== id) continue;
1163
+ n.close();
1164
+ }
1165
+ } catch {}
1166
+ }
1167
+ /**
1168
+ * Show the in-page `Notification` for a fired timer. Tolerant of permission
1169
+ * being revoked between scheduling and firing (construction can throw).
1170
+ */
1171
+ function fireInPageNotification(record, id) {
1172
+ try {
1173
+ const N = globalThis.Notification;
1174
+ new N(record.title, {
1175
+ body: record.body,
1176
+ data: {
1177
+ ...record.data,
1178
+ __ambaLocalId: id
1179
+ },
1180
+ tag: id
1181
+ });
1182
+ } catch {}
1183
+ }
1184
+ /**
1185
+ * Arm the next chunk of an in-page timer for the registry entry under `id`.
1186
+ * If the remaining delay until `entry.fireAt` exceeds `MAX_TIMEOUT_MS`, arm
1187
+ * for `MAX_TIMEOUT_MS` and re-arm on wake; otherwise arm for the exact
1188
+ * remainder, then fire and remove the entry.
1189
+ *
1190
+ * `entry.handle` is reassigned to each chunk's timeout id, so a concurrent
1191
+ * `clearTimeout(entry.handle)` (cancel / reschedule) always targets the live
1192
+ * chunk and stops the chain.
1193
+ */
1194
+ function armTimerChunk(id, entry) {
1195
+ const remaining = entry.fireAt - Date.now();
1196
+ if (remaining > MAX_TIMEOUT_MS) {
1197
+ entry.handle = setTimeout(() => {
1198
+ if (pendingTimers.get(id) === entry) armTimerChunk(id, entry);
1199
+ }, MAX_TIMEOUT_MS);
1200
+ return;
1201
+ }
1202
+ entry.handle = setTimeout(() => {
1203
+ if (pendingTimers.get(id) !== entry) return;
1204
+ pendingTimers.delete(id);
1205
+ fireInPageNotification(entry.record, id);
1206
+ }, Math.max(0, remaining));
1207
+ }
1208
+ /**
1209
+ * The web `notifications` namespace. Web capability is partial — see the
1210
+ * module-level doc. The returned handle includes `scheduledVia` so apps
1211
+ * can detect whether the notification was handed to the OS
1212
+ * (`"os-trigger"`) or relies on an in-page timer (`"timer"`).
1213
+ */
1214
+ const notifications = {
1215
+ async scheduleLocal(input) {
1216
+ validateContent(input);
1217
+ if (!hasNotificationApi()) throw new AmbaApiError("LOCAL_NOTIFICATIONS_UNAVAILABLE", "the Notifications API is not available in this environment (server-side rendering, or a browser with notifications disabled). Local notifications require a browser context.");
1218
+ const parsedTrigger = parseTrigger(input);
1219
+ const id = normalizeScheduleId(input.id);
1220
+ await ensurePermission();
1221
+ const fireAt = computeFireAt(parsedTrigger);
1222
+ const instanceToken = genInstanceToken();
1223
+ const TriggerCtor = timestampTriggerCtor();
1224
+ const registration = await getServiceWorkerRegistration();
1225
+ if (TriggerCtor && registration) {
1226
+ await registration.showNotification(input.title, {
1227
+ body: input.body,
1228
+ data: {
1229
+ ...input.data,
1230
+ __ambaLocalId: id,
1231
+ __ambaInstance: instanceToken
1232
+ },
1233
+ tag: id,
1234
+ showTrigger: new TriggerCtor(fireAt)
1235
+ });
1236
+ const priorTimer$1 = pendingTimers.get(id);
1237
+ if (priorTimer$1) {
1238
+ clearTimeout(priorTimer$1.handle);
1239
+ pendingTimers.delete(id);
1240
+ }
1241
+ return {
1242
+ id,
1243
+ scheduledVia: "os-trigger"
1244
+ };
1245
+ }
1246
+ const priorTimer = pendingTimers.get(id);
1247
+ if (priorTimer) {
1248
+ clearTimeout(priorTimer.handle);
1249
+ pendingTimers.delete(id);
1250
+ }
1251
+ await cancelOsTriggerNotification(registration, id);
1252
+ const entry = {
1253
+ handle: void 0,
1254
+ fireAt,
1255
+ record: {
1256
+ id,
1257
+ title: input.title,
1258
+ body: input.body,
1259
+ data: input.data
1260
+ }
1261
+ };
1262
+ pendingTimers.set(id, entry);
1263
+ armTimerChunk(id, entry);
1264
+ return {
1265
+ id,
1266
+ scheduledVia: "timer"
1267
+ };
1268
+ },
1269
+ async cancelLocal(id) {
1270
+ if (typeof id !== "string" || id.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.notifications.cancelLocal: id is required");
1271
+ const timer = pendingTimers.get(id);
1272
+ if (timer) {
1273
+ clearTimeout(timer.handle);
1274
+ pendingTimers.delete(id);
1275
+ }
1276
+ await cancelOsTriggerNotification(await getServiceWorkerRegistration(), id);
1277
+ },
1278
+ async listScheduledLocal() {
1279
+ const out = [];
1280
+ for (const { record } of pendingTimers.values()) out.push({ ...record });
1281
+ const registration = await getServiceWorkerRegistration();
1282
+ if (registration) try {
1283
+ const [withTriggered, shownOnly] = await Promise.all([registration.getNotifications({ includeTriggered: true }), registration.getNotifications()]);
1284
+ const shownInstances = /* @__PURE__ */ new Set();
1285
+ for (const n of shownOnly) {
1286
+ const token = (n.data ?? {}).__ambaInstance;
1287
+ if (token) shownInstances.add(token);
1288
+ }
1289
+ for (const n of withTriggered) {
1290
+ const data = n.data ?? {};
1291
+ const id = data.__ambaLocalId;
1292
+ if (!id) continue;
1293
+ const token = data.__ambaInstance;
1294
+ if (token && shownInstances.has(token)) continue;
1295
+ if (out.some((existing) => existing.id === id)) continue;
1296
+ out.push({
1297
+ id,
1298
+ title: n.title,
1299
+ body: n.body ?? "",
1300
+ data
1301
+ });
1302
+ }
1303
+ } catch {}
1304
+ return out;
1305
+ }
1306
+ };
257
1307
 
258
1308
  //#endregion
259
1309
  //#region src/index.ts
@@ -296,6 +1346,153 @@ let _currentSessionToken = null;
296
1346
  */
297
1347
  let _configuredFunctionsBaseUrl = null;
298
1348
  let _resolvedFunctionsProjectId = null;
1349
+ /**
1350
+ * Offline-resilience state. `_persistence` is the localStorage-backed store
1351
+ * captured at configure-time; `_offlineQueue` is lazily built the first time
1352
+ * `Amba.offline.enable()` runs. `_offlineEnabled` gates whether failed writes
1353
+ * are diverted to the durable outbox (opt-in — default behavior is unchanged,
1354
+ * a failed write throws). All three reset on every `configure()`.
1355
+ */
1356
+ let _persistence = null;
1357
+ let _offlineQueue = null;
1358
+ let _offlineEnabled = false;
1359
+ /**
1360
+ * Detacher for the active queue's `online` auto-flush listener, if installed.
1361
+ * Held so a `configure()` (or a fresh `enable()`) can tear it down — otherwise
1362
+ * the previous queue's listener leaks and fires against a dead queue.
1363
+ */
1364
+ let _offlineDetach = null;
1365
+ /**
1366
+ * Monotonic token identifying the CURRENT offline activation. Bumped on every
1367
+ * `enable()` AND every teardown/`configure()`, and captured by each `enable()`
1368
+ * into its disable callback. A disabler only acts while the token still matches
1369
+ * — so a stale disabler (held across a re-`enable()` OR a reconfigure) becomes
1370
+ * a no-op instead of wrongly turning the now-active outbox off.
1371
+ */
1372
+ let _offlineEnableToken = 0;
1373
+ /**
1374
+ * Tear down any active offline wiring: detach the `online` listener, DISPOSE
1375
+ * the active queue (awaiting any in-flight flush so it can never write the
1376
+ * shared outbox again), and drop the references. Bumps the generation so stale
1377
+ * `enable()` disable callbacks no-op. Called before re-initializing on
1378
+ * `configure()`. Async so the in-flight flush is awaited before a replacement
1379
+ * queue is installed — preventing an orphaned flush from racing the new queue.
1380
+ */
1381
+ async function teardownOffline() {
1382
+ if (_offlineDetach) {
1383
+ _offlineDetach();
1384
+ _offlineDetach = null;
1385
+ }
1386
+ const old = _offlineQueue;
1387
+ _offlineQueue = null;
1388
+ _offlineEnabled = false;
1389
+ _offlineEnableToken += 1;
1390
+ if (old) await old.dispose();
1391
+ }
1392
+ /**
1393
+ * Bridge the localStorage persistence adapter to the {@link KvStore} contract
1394
+ * the offline outbox persists through. Namespaced under the configured
1395
+ * storage prefix so multiple projects in one origin don't collide.
1396
+ */
1397
+ function offlineStore() {
1398
+ const p = _persistence;
1399
+ if (!p) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before using Amba.offline.");
1400
+ return {
1401
+ get: (key) => p.read(key),
1402
+ set: (key, value) => p.write(key, value),
1403
+ delete: (key) => p.delete(key)
1404
+ };
1405
+ }
1406
+ /**
1407
+ * Replay a single queued mutation against the live API, forwarding the op's
1408
+ * idempotency key on collection writes so a write that landed before the
1409
+ * connection dropped is never double-applied. Used as the {@link OfflineQueue}
1410
+ * executor.
1411
+ */
1412
+ async function replayOutboxOp(op) {
1413
+ switch (op.kind) {
1414
+ case "collection.insert":
1415
+ await coreCall((c) => c.collectionsInsert(op.collection, encodeJson({
1416
+ ...op.payload ?? {},
1417
+ idempotency_key: op.idempotencyKey
1418
+ })));
1419
+ return;
1420
+ case "collection.update":
1421
+ await coreCall((c) => c.collectionsUpdate(op.collection, op.id, encodeJson(op.payload ?? {})));
1422
+ return;
1423
+ case "collection.delete":
1424
+ await coreCall((c) => c.collectionsDelete(op.collection, op.id));
1425
+ return;
1426
+ case "track":
1427
+ if (op.occurredAt !== void 0) await replayTrackDirect(op);
1428
+ else await coreCall((c) => c.track(op.event, op.properties ? encodeJson(op.properties) : void 0, op.telemetry));
1429
+ return;
1430
+ }
1431
+ }
1432
+ /**
1433
+ * Replay a buffered `track` op via a direct POST to `/v1/client/events`,
1434
+ * preserving the original `timestamp` captured at enqueue (the events route
1435
+ * honors body `timestamp` → `occurred_at`, but attributes the event to the
1436
+ * current session user — it has no body-`anonymous_id` attribution). Throws on
1437
+ * a transport failure or non-2xx so the queue retains the op.
1438
+ *
1439
+ * A `401`/`403` (no / expired session at replay) is surfaced as a coded
1440
+ * `NETWORK_ERROR` so the outbox RETAINS the event for a later attempt — an
1441
+ * unauthenticated replay must NOT be dropped as a deterministic rejection
1442
+ * (the session may refresh and the event then lands).
1443
+ */
1444
+ async function replayTrackDirect(op) {
1445
+ const headers = { "content-type": "application/json" };
1446
+ if (_configuredClientKey) headers["X-Api-Key"] = _configuredClientKey;
1447
+ if (_currentSessionToken) headers["Authorization"] = `Bearer ${_currentSessionToken}`;
1448
+ const body = {
1449
+ event: op.event,
1450
+ properties: op.properties ?? void 0,
1451
+ timestamp: op.occurredAt,
1452
+ ...op.telemetry ? { telemetry: true } : {}
1453
+ };
1454
+ const eventsUrl = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/events`;
1455
+ let resp;
1456
+ try {
1457
+ resp = await fetch(eventsUrl, {
1458
+ method: "POST",
1459
+ headers,
1460
+ body: JSON.stringify(body)
1461
+ });
1462
+ } catch (err) {
1463
+ throw new AmbaApiError("NETWORK_ERROR", `track replay network error: ${String(err)}`);
1464
+ }
1465
+ if (!resp.ok) {
1466
+ if (resp.status === 401 || resp.status === 403) throw new AmbaApiError("NETWORK_ERROR", `track replay deferred: no valid session (status ${resp.status})`);
1467
+ const text = await resp.text().catch(() => "");
1468
+ let code = `HTTP_${resp.status}`;
1469
+ try {
1470
+ const parsed = JSON.parse(text);
1471
+ if (typeof parsed?.error?.code === "string") code = parsed.error.code;
1472
+ } catch {}
1473
+ throw new AmbaApiError(code, `track replay failed with status ${resp.status}`, text);
1474
+ }
1475
+ }
1476
+ function getOfflineQueue() {
1477
+ if (!_offlineQueue) _offlineQueue = new OfflineQueue({
1478
+ store: offlineStore(),
1479
+ storageKey: `offline_outbox:${_configuredProjectId ?? "default"}`,
1480
+ executor: replayOutboxOp,
1481
+ onDropped: (op, err) => {
1482
+ if (typeof console !== "undefined") console.warn(`[amba] dropped un-replayable offline ${op.kind} (${op.collection ?? op.event ?? "?"}):`, err);
1483
+ }
1484
+ });
1485
+ return _offlineQueue;
1486
+ }
1487
+ async function insertDirect(name, row) {
1488
+ return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
1489
+ }
1490
+ async function updateDirect(name, id, set) {
1491
+ return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
1492
+ }
1493
+ async function deleteDirect(name, id) {
1494
+ return await coreCall((c) => c.collectionsDelete(name, id));
1495
+ }
299
1496
  function requireCore() {
300
1497
  if (!coreInstance) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start before any other API.");
301
1498
  return coreInstance;
@@ -316,6 +1513,26 @@ async function coreCall(fn) {
316
1513
  }
317
1514
  }
318
1515
  const authSubscribers = /* @__PURE__ */ new Set();
1516
+ /**
1517
+ * Translate a public `TransactionOp` (camelCase `onConflict` /
1518
+ * `conflictTarget`) into the server's wire op (snake_case `on_conflict` /
1519
+ * `conflict_target`). Unset optionals are dropped so the server applies its
1520
+ * defaults.
1521
+ */
1522
+ function toWireTransactionOp(op) {
1523
+ const wire = {
1524
+ collection: op.collection,
1525
+ op: op.op
1526
+ };
1527
+ if (op.id !== void 0) wire["id"] = op.id;
1528
+ if (op.row !== void 0) wire["row"] = op.row;
1529
+ if (op.set !== void 0) wire["set"] = op.set;
1530
+ if (op.where !== void 0) wire["where"] = op.where;
1531
+ if (op.expected !== void 0) wire["expected"] = op.expected;
1532
+ if (op.onConflict !== void 0) wire["on_conflict"] = op.onConflict;
1533
+ if (op.conflictTarget !== void 0) wire["conflict_target"] = op.conflictTarget;
1534
+ return wire;
1535
+ }
319
1536
  function snapshotSession(user) {
320
1537
  if (!user) return null;
321
1538
  return {
@@ -348,6 +1565,35 @@ function realtimeAuthHeaders() {
348
1565
  Authorization: `Bearer ${_currentSessionToken}`
349
1566
  };
350
1567
  }
1568
+ /** SDK-facing AI streaming routes (vendor split is a stable wire shape). */
1569
+ const AI_ANTHROPIC_PATH = "/v1/client/ai/anthropic/messages";
1570
+ const AI_OPENAI_PATH = "/v1/client/ai/openai/chat/completions";
1571
+ /**
1572
+ * Build the request (url + headers + body) for a streamed AI invocation.
1573
+ * Forces `stream: true` onto the request, injects the client key + live
1574
+ * session token, and targets the configured API base. Shared by the
1575
+ * Anthropic + OpenAI streaming methods so the auth/path wiring can't drift.
1576
+ */
1577
+ function buildAiStreamRequest(path, request) {
1578
+ requireCore();
1579
+ if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before streaming AI.");
1580
+ if (!request.prompt_slug || request.prompt_slug.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "prompt_slug is required");
1581
+ const headers = {
1582
+ "X-Api-Key": _configuredClientKey,
1583
+ "content-type": "application/json",
1584
+ accept: "text/event-stream"
1585
+ };
1586
+ if (_currentSessionToken) headers["Authorization"] = `Bearer ${_currentSessionToken}`;
1587
+ const expanded = expandAiImages(request);
1588
+ return {
1589
+ url: `${_configuredApiUrl.replace(/\/$/, "")}${path}`,
1590
+ headers,
1591
+ body: JSON.stringify({
1592
+ ...expanded,
1593
+ stream: true
1594
+ })
1595
+ };
1596
+ }
351
1597
  /**
352
1598
  * Resolve the full invocation URL for a deployed function.
353
1599
  *
@@ -377,9 +1623,11 @@ const Amba = {
377
1623
  if (!config.projectId || config.projectId.trim() === "") throw new Error("Amba.configure: projectId is required");
378
1624
  if (!config.clientKey || config.clientKey.trim() === "") throw new Error("Amba.configure: clientKey is required");
379
1625
  await init(); // Hermes-stripped Node-init block (DX-15) — see scripts/build-rn.mjs
380
- new LocalStoragePersistence(config.storagePrefix ?? "amba").hydrate();
1626
+ const persistence = new LocalStoragePersistence(config.storagePrefix ?? "amba");
1627
+ persistence.hydrate();
1628
+ let nextCore;
381
1629
  try {
382
- coreInstance = AmbaCoreWasm.init(encodeJson({
1630
+ nextCore = AmbaCoreWasm.init(encodeJson({
383
1631
  api_key: config.clientKey,
384
1632
  base_url: config.apiUrl,
385
1633
  project_id: config.projectId,
@@ -391,6 +1639,9 @@ const Amba = {
391
1639
  } catch (err) {
392
1640
  throw toAmbaApiError(err);
393
1641
  }
1642
+ await teardownOffline();
1643
+ _persistence = persistence;
1644
+ coreInstance = nextCore;
394
1645
  _configuredProjectId = config.projectId;
395
1646
  _configuredApiUrl = config.apiUrl ?? DEFAULT_API_URL;
396
1647
  _configuredClientKey = config.clientKey;
@@ -413,9 +1664,59 @@ const Amba = {
413
1664
  setDebug: (enabled) => {
414
1665
  requireCore().setDebug(enabled);
415
1666
  },
416
- events: { track: async (event, properties, options) => {
417
- await coreCall((c) => c.track(event, properties ? encodeJson(properties) : void 0, options?.telemetry));
418
- } },
1667
+ events: {
1668
+ track: async (event, properties, options) => {
1669
+ try {
1670
+ await coreCall((c) => c.track(event, properties ? encodeJson(properties) : void 0, options?.telemetry));
1671
+ } catch (err) {
1672
+ if (_offlineEnabled && isOfflineError(err)) {
1673
+ await getOfflineQueue().enqueue({
1674
+ kind: "track",
1675
+ event,
1676
+ properties,
1677
+ telemetry: options?.telemetry,
1678
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString()
1679
+ });
1680
+ return;
1681
+ }
1682
+ throw err;
1683
+ }
1684
+ },
1685
+ explain: async (event, properties, options) => {
1686
+ if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before Amba.events.explain().");
1687
+ if (!event || event.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.events.explain: an event name is required.");
1688
+ const candidate = { name: event };
1689
+ if (properties !== void 0) candidate["properties"] = properties;
1690
+ if (options && "userId" in options) candidate["user_id"] = options.userId ?? null;
1691
+ const headers = {
1692
+ "Content-Type": "application/json",
1693
+ "X-Api-Key": _configuredClientKey
1694
+ };
1695
+ if (_currentSessionToken) headers["Authorization"] = `Bearer ${_currentSessionToken}`;
1696
+ let resp;
1697
+ try {
1698
+ resp = await fetch(`${_configuredApiUrl.replace(/\/$/, "")}/v1/client/events/explain`, {
1699
+ method: "POST",
1700
+ headers,
1701
+ body: JSON.stringify({ event: candidate })
1702
+ });
1703
+ } catch (err) {
1704
+ throw new AmbaApiError("NETWORK_ERROR", `Amba.events.explain("${event}") network error: ${String(err)}`);
1705
+ }
1706
+ const text = await resp.text();
1707
+ let parsed = void 0;
1708
+ if (text.length > 0) try {
1709
+ parsed = JSON.parse(text);
1710
+ } catch {
1711
+ parsed = void 0;
1712
+ }
1713
+ if (!resp.ok) {
1714
+ const envelope = parsed && typeof parsed === "object" ? parsed : void 0;
1715
+ throw new AmbaApiError(typeof envelope?.error?.code === "string" ? envelope.error.code : `HTTP_${resp.status}`, typeof envelope?.error?.message === "string" ? envelope.error.message : `Amba.events.explain("${event}") failed with status ${resp.status}`, parsed);
1716
+ }
1717
+ return parsed.data;
1718
+ }
1719
+ },
419
1720
  auth: {
420
1721
  signInAnonymously: async () => {
421
1722
  const r = await coreCall((c) => c.signInAnonymously());
@@ -553,20 +1854,106 @@ const Amba = {
553
1854
  return await coreCall((c) => c.collectionsFindOne(name, id));
554
1855
  },
555
1856
  insert: async (name, row) => {
1857
+ if (_offlineEnabled) {
1858
+ const idempotencyKey = (typeof row["idempotency_key"] === "string" ? row["idempotency_key"] : void 0) ?? newIdempotencyKey();
1859
+ const wireRow = {
1860
+ ...row,
1861
+ idempotency_key: idempotencyKey
1862
+ };
1863
+ try {
1864
+ return await coreCall((c) => c.collectionsInsert(name, encodeJson(wireRow)));
1865
+ } catch (err) {
1866
+ if (isOfflineError(err)) {
1867
+ await getOfflineQueue().enqueue({
1868
+ kind: "collection.insert",
1869
+ collection: name,
1870
+ payload: row,
1871
+ idempotencyKey
1872
+ });
1873
+ return {
1874
+ ...row,
1875
+ _amba_pending: true
1876
+ };
1877
+ }
1878
+ throw err;
1879
+ }
1880
+ }
556
1881
  return await coreCall((c) => c.collectionsInsert(name, encodeJson(row)));
557
1882
  },
558
1883
  update: async (name, id, set) => {
1884
+ if (_offlineEnabled) try {
1885
+ return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
1886
+ } catch (err) {
1887
+ if (isOfflineError(err)) {
1888
+ await getOfflineQueue().enqueue({
1889
+ kind: "collection.update",
1890
+ collection: name,
1891
+ id,
1892
+ payload: set
1893
+ });
1894
+ return {
1895
+ id,
1896
+ ...set,
1897
+ _amba_pending: true
1898
+ };
1899
+ }
1900
+ throw err;
1901
+ }
559
1902
  return await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set)));
560
1903
  },
561
1904
  delete: async (name, id) => {
1905
+ if (_offlineEnabled) try {
1906
+ return await coreCall((c) => c.collectionsDelete(name, id));
1907
+ } catch (err) {
1908
+ if (isOfflineError(err)) {
1909
+ await getOfflineQueue().enqueue({
1910
+ kind: "collection.delete",
1911
+ collection: name,
1912
+ id
1913
+ });
1914
+ return { data: { deleted: true } };
1915
+ }
1916
+ throw err;
1917
+ }
562
1918
  return await coreCall((c) => c.collectionsDelete(name, id));
563
1919
  },
1920
+ upsert: async (name, row, options) => {
1921
+ const wire = {
1922
+ on_conflict: options.onConflict,
1923
+ conflict_target: options.conflictTarget
1924
+ };
1925
+ return await coreCall((c) => c.collectionsUpsert(name, encodeJson(row), encodeJson(wire)));
1926
+ },
1927
+ deleteWhere: async (name, filter) => {
1928
+ return await coreCall((c) => c.collectionsDeleteWhere(name, encodeJson(filter)));
1929
+ },
1930
+ restore: async (name, id) => {
1931
+ return await coreCall((c) => c.collectionsRestore(name, id));
1932
+ },
1933
+ transaction: async (operations) => {
1934
+ return (await coreCall((c) => c.collectionsTransaction(encodeJson(operations.map(toWireTransactionOp))))).data;
1935
+ },
564
1936
  findNearest: async (name, options) => {
565
1937
  return await coreCall((c) => c.collectionsFindNearest(name, encodeJson(options)));
566
1938
  },
567
1939
  count: async (name, filter) => {
568
1940
  return await coreCall((c) => c.collectionsCount(name, filter ? encodeJson(filter) : void 0));
569
1941
  },
1942
+ optimisticInsert: async (name, row, view) => withOptimistic({
1943
+ apply: view.apply,
1944
+ mutate: () => insertDirect(name, row),
1945
+ rollback: view.rollback
1946
+ }),
1947
+ optimisticUpdate: async (name, id, set, view) => withOptimistic({
1948
+ apply: view.apply,
1949
+ mutate: () => updateDirect(name, id, set),
1950
+ rollback: view.rollback
1951
+ }),
1952
+ optimisticDelete: async (name, id, view) => withOptimistic({
1953
+ apply: view.apply,
1954
+ mutate: () => deleteDirect(name, id),
1955
+ rollback: view.rollback
1956
+ }),
570
1957
  where: {
571
1958
  eq: (column, value) => ({
572
1959
  column,
@@ -652,7 +2039,7 @@ const Amba = {
652
2039
  headers: uploadHeaders,
653
2040
  body: params.file
654
2041
  });
655
- if (!putResp.ok) throw new Error(`upload to R2 failed: status ${putResp.status}`);
2042
+ if (!putResp.ok) throw new Error(`upload to storage failed: status ${putResp.status}`);
656
2043
  return await coreCall((c) => c.storageCommit(presign.upload_id, presign.asset_id));
657
2044
  },
658
2045
  list: async (prefix) => {
@@ -666,6 +2053,32 @@ const Amba = {
666
2053
  return new Blob([bytes]);
667
2054
  }
668
2055
  },
2056
+ media: { catalog: async (slug) => {
2057
+ if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before reading a media catalog.");
2058
+ if (!slug || slug.trim() === "") throw new AmbaApiError("INVALID_ARGUMENT", "Amba.media.catalog: a catalog slug is required.");
2059
+ const url = `${_configuredApiUrl.replace(/\/$/, "")}/v1/client/media/catalogs/${encodeURIComponent(slug)}`;
2060
+ let resp;
2061
+ try {
2062
+ resp = await fetch(url, {
2063
+ method: "GET",
2064
+ headers: { "X-Api-Key": _configuredClientKey }
2065
+ });
2066
+ } catch (err) {
2067
+ throw new AmbaApiError("NETWORK_ERROR", `Amba.media.catalog("${slug}") network error: ${String(err)}`);
2068
+ }
2069
+ const text = await resp.text();
2070
+ let parsed = void 0;
2071
+ if (text.length > 0) try {
2072
+ parsed = JSON.parse(text);
2073
+ } catch {
2074
+ parsed = void 0;
2075
+ }
2076
+ if (!resp.ok) {
2077
+ const envelope = parsed && typeof parsed === "object" ? parsed : void 0;
2078
+ throw new AmbaApiError(typeof envelope?.error?.code === "string" ? envelope.error.code : `HTTP_${resp.status}`, typeof envelope?.error?.message === "string" ? envelope.error.message : `Amba.media.catalog("${slug}") failed with status ${resp.status}`, parsed);
2079
+ }
2080
+ return parsed.data;
2081
+ } },
669
2082
  sync: {
670
2083
  pushChanges: async (changes) => {
671
2084
  return await coreCall((c) => c.syncPushChanges(encodeJson(changes)));
@@ -674,6 +2087,33 @@ const Amba = {
674
2087
  return await coreCall((c) => c.syncPullChanges(encodeJson(since)));
675
2088
  }
676
2089
  },
2090
+ offline: {
2091
+ enable: () => {
2092
+ if (_offlineDetach) {
2093
+ _offlineDetach();
2094
+ _offlineDetach = null;
2095
+ }
2096
+ _offlineEnabled = true;
2097
+ _offlineEnableToken += 1;
2098
+ const token = _offlineEnableToken;
2099
+ const q = getOfflineQueue();
2100
+ _offlineDetach = q.startAutoFlush();
2101
+ q.flush();
2102
+ return () => {
2103
+ if (_offlineEnableToken !== token) return;
2104
+ _offlineEnabled = false;
2105
+ if (_offlineDetach) {
2106
+ _offlineDetach();
2107
+ _offlineDetach = null;
2108
+ }
2109
+ };
2110
+ },
2111
+ isEnabled: () => _offlineEnabled,
2112
+ pendingCount: async () => getOfflineQueue().pendingCount(),
2113
+ pending: async () => getOfflineQueue().pending(),
2114
+ flush: async () => getOfflineQueue().flush(),
2115
+ clear: async () => getOfflineQueue().clear()
2116
+ },
677
2117
  push: {
678
2118
  register: async (token, platform, bundleId) => {
679
2119
  return await coreCall((c) => c.pushRegister(token, platform, bundleId ?? void 0));
@@ -689,19 +2129,49 @@ const Amba = {
689
2129
  },
690
2130
  unsubscribe: async (topic) => {
691
2131
  await coreCall((c) => c.pushUnsubscribe(topic));
692
- }
2132
+ },
2133
+ enableAutoRotation: () => enableWebPushAutoRotation((token) => Amba.push.register(token, "web"))
693
2134
  },
2135
+ notifications,
694
2136
  entitlements: {
695
2137
  list: async () => {
696
2138
  return await coreCall((c) => c.entitlementsList());
697
2139
  },
698
2140
  has: async (name) => {
699
2141
  return await coreCall((c) => c.entitlementsHas(name));
2142
+ },
2143
+ restore: async () => {
2144
+ return await coreCall((c) => c.entitlementsRestore());
700
2145
  }
701
2146
  },
702
- ai: { anthropic: { messages: { create: async (request) => {
703
- return await coreCall((c) => c.aiAnthropicMessages(encodeJson(request)));
704
- } } } },
2147
+ offerings: async (offeringId) => {
2148
+ return await coreCall((c) => offeringId ? c.offeringsGet(offeringId) : c.offeringsList());
2149
+ },
2150
+ ai: {
2151
+ anthropic: { messages: {
2152
+ create: async (request) => {
2153
+ return await coreCall((c) => c.aiAnthropicMessages(encodeJson(expandAiImages(request))));
2154
+ },
2155
+ stream: (request, options) => {
2156
+ const { url, headers, body } = buildAiStreamRequest(AI_ANTHROPIC_PATH, request);
2157
+ return invokeAiStream({
2158
+ url,
2159
+ headers,
2160
+ body,
2161
+ onChunk: options?.onChunk
2162
+ });
2163
+ }
2164
+ } },
2165
+ openai: { chat: { completions: { stream: (request, options) => {
2166
+ const { url, headers, body } = buildAiStreamRequest(AI_OPENAI_PATH, request);
2167
+ return invokeAiStream({
2168
+ url,
2169
+ headers,
2170
+ body,
2171
+ onChunk: options?.onChunk
2172
+ });
2173
+ } } } }
2174
+ },
705
2175
  functions: { invoke: async (name, body, options) => {
706
2176
  requireCore();
707
2177
  if (!_configuredClientKey) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) before Amba.functions.invoke().");
@@ -735,9 +2205,14 @@ const Amba = {
735
2205
  } catch {
736
2206
  parsed = text;
737
2207
  }
2208
+ const envelopeRequestId = parsed && typeof parsed === "object" ? parsed.error?.request_id ?? parsed.request_id : void 0;
2209
+ const traceId = resp.headers.get("X-Amba-Request-Id") ?? (typeof envelopeRequestId === "string" ? envelopeRequestId : null);
2210
+ options?.onTraceId?.(traceId);
738
2211
  if (!resp.ok) {
739
2212
  const envelope = parsed && typeof parsed === "object" ? parsed : void 0;
740
- throw new AmbaApiError(typeof envelope?.error?.code === "string" ? envelope.error.code : `HTTP_${resp.status}`, typeof envelope?.error?.message === "string" ? envelope.error.message : typeof parsed === "string" && parsed.length > 0 ? parsed : `Amba.functions.invoke("${name}") failed with status ${resp.status}`, parsed);
2213
+ const error = new AmbaApiError(typeof envelope?.error?.code === "string" ? envelope.error.code : `HTTP_${resp.status}`, typeof envelope?.error?.message === "string" ? envelope.error.message : typeof parsed === "string" && parsed.length > 0 ? parsed : `Amba.functions.invoke("${name}") failed with status ${resp.status}`, parsed);
2214
+ if (traceId !== null) error.traceId = traceId;
2215
+ throw error;
741
2216
  }
742
2217
  return parsed;
743
2218
  } },
@@ -802,6 +2277,12 @@ const Amba = {
802
2277
  getAll: async () => await coreCall((c) => c.streaksGetAll()),
803
2278
  qualify: async (streakKey) => await coreCall((c) => c.streaksQualify(streakKey))
804
2279
  },
2280
+ get gamification() {
2281
+ return new GamificationSubscription({
2282
+ baseUrl: _configuredApiUrl,
2283
+ authHeaders: realtimeAuthHeaders
2284
+ });
2285
+ },
805
2286
  feeds: { getActivity: async (feed, cursor) => await coreCall((c) => c.feedsGetActivity(feed ?? void 0, cursor ?? void 0)) },
806
2287
  friends: {
807
2288
  getList: async () => await coreCall((c) => c.friendsGetList()),
@@ -910,8 +2391,8 @@ function getCoreWasm() {
910
2391
  * `verify-sdk-version-coherence` GH action gates every publish on the
911
2392
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
912
2393
  */
913
- const SDK_VERSION = "4.0.4";
2394
+ const SDK_VERSION = "4.0.5";
914
2395
 
915
2396
  //#endregion
916
- export { Amba, AmbaApiError, CollectionSubscription, ConversationSubscription, SDK_VERSION, getCoreWasm };
2397
+ export { Amba, AmbaApiError, CollectionSubscription, ConversationSubscription, GamificationSubscription, SDK_VERSION, getCoreWasm, withOptimistic };
917
2398
  //# sourceMappingURL=index.js.map