@rocapine/rocalytics-sdk 0.0.0-stage → 0.3.0

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.
Files changed (161) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +449 -2
  4. package/client/package.json +1 -0
  5. package/core/package.json +4 -0
  6. package/dist/client/client.d.ts +146 -0
  7. package/dist/client/client.d.ts.map +1 -0
  8. package/dist/client/client.js +376 -0
  9. package/dist/client/client.js.map +1 -0
  10. package/dist/client/index.d.ts +13 -0
  11. package/dist/client/index.d.ts.map +1 -0
  12. package/dist/client/index.js +35 -0
  13. package/dist/client/index.js.map +1 -0
  14. package/dist/client/native.d.ts +68 -0
  15. package/dist/client/native.d.ts.map +1 -0
  16. package/dist/client/native.js +64 -0
  17. package/dist/client/native.js.map +1 -0
  18. package/dist/client/onboardingSink.d.ts +63 -0
  19. package/dist/client/onboardingSink.d.ts.map +1 -0
  20. package/dist/client/onboardingSink.js +126 -0
  21. package/dist/client/onboardingSink.js.map +1 -0
  22. package/dist/client/paywallSink.d.ts +13 -0
  23. package/dist/client/paywallSink.d.ts.map +1 -0
  24. package/dist/client/paywallSink.js +26 -0
  25. package/dist/client/paywallSink.js.map +1 -0
  26. package/dist/client/requests.d.ts +66 -0
  27. package/dist/client/requests.d.ts.map +1 -0
  28. package/dist/client/requests.js +90 -0
  29. package/dist/client/requests.js.map +1 -0
  30. package/dist/client/types.d.ts +180 -0
  31. package/dist/client/types.d.ts.map +1 -0
  32. package/dist/client/types.js +4 -0
  33. package/dist/client/types.js.map +1 -0
  34. package/dist/core/context.d.ts +39 -0
  35. package/dist/core/context.d.ts.map +1 -0
  36. package/dist/core/context.js +45 -0
  37. package/dist/core/context.js.map +1 -0
  38. package/dist/core/delivery.d.ts +63 -0
  39. package/dist/core/delivery.d.ts.map +1 -0
  40. package/dist/core/delivery.js +133 -0
  41. package/dist/core/delivery.js.map +1 -0
  42. package/dist/core/diagnostics.d.ts +16 -0
  43. package/dist/core/diagnostics.d.ts.map +1 -0
  44. package/dist/core/diagnostics.js +24 -0
  45. package/dist/core/diagnostics.js.map +1 -0
  46. package/dist/core/httpSink.d.ts +35 -0
  47. package/dist/core/httpSink.d.ts.map +1 -0
  48. package/dist/core/httpSink.js +65 -0
  49. package/dist/core/httpSink.js.map +1 -0
  50. package/dist/core/ids.d.ts +16 -0
  51. package/dist/core/ids.d.ts.map +1 -0
  52. package/dist/core/ids.js +51 -0
  53. package/dist/core/ids.js.map +1 -0
  54. package/dist/core/index.d.ts +17 -0
  55. package/dist/core/index.d.ts.map +1 -0
  56. package/dist/core/index.js +32 -0
  57. package/dist/core/index.js.map +1 -0
  58. package/dist/core/sink.d.ts +39 -0
  59. package/dist/core/sink.d.ts.map +1 -0
  60. package/dist/core/sink.js +16 -0
  61. package/dist/core/sink.js.map +1 -0
  62. package/dist/core/storage.d.ts +52 -0
  63. package/dist/core/storage.d.ts.map +1 -0
  64. package/dist/core/storage.js +79 -0
  65. package/dist/core/storage.js.map +1 -0
  66. package/dist/core/text.d.ts +9 -0
  67. package/dist/core/text.d.ts.map +1 -0
  68. package/dist/core/text.js +47 -0
  69. package/dist/core/text.js.map +1 -0
  70. package/dist/core/time.d.ts +18 -0
  71. package/dist/core/time.d.ts.map +1 -0
  72. package/dist/core/time.js +18 -0
  73. package/dist/core/time.js.map +1 -0
  74. package/dist/onboarding/contract.d.ts +138 -0
  75. package/dist/onboarding/contract.d.ts.map +1 -0
  76. package/dist/onboarding/contract.js +11 -0
  77. package/dist/onboarding/contract.js.map +1 -0
  78. package/dist/onboarding/index.d.ts +15 -0
  79. package/dist/onboarding/index.d.ts.map +1 -0
  80. package/dist/onboarding/index.js +22 -0
  81. package/dist/onboarding/index.js.map +1 -0
  82. package/dist/onboarding/input.d.ts +102 -0
  83. package/dist/onboarding/input.d.ts.map +1 -0
  84. package/dist/onboarding/input.js +216 -0
  85. package/dist/onboarding/input.js.map +1 -0
  86. package/dist/onboarding/persisted.d.ts +20 -0
  87. package/dist/onboarding/persisted.d.ts.map +1 -0
  88. package/dist/onboarding/persisted.js +75 -0
  89. package/dist/onboarding/persisted.js.map +1 -0
  90. package/dist/onboarding/state.d.ts +65 -0
  91. package/dist/onboarding/state.d.ts.map +1 -0
  92. package/dist/onboarding/state.js +207 -0
  93. package/dist/onboarding/state.js.map +1 -0
  94. package/dist/onboarding/tracker.d.ts +135 -0
  95. package/dist/onboarding/tracker.d.ts.map +1 -0
  96. package/dist/onboarding/tracker.js +734 -0
  97. package/dist/onboarding/tracker.js.map +1 -0
  98. package/dist/paywall/contract.d.ts +67 -0
  99. package/dist/paywall/contract.d.ts.map +1 -0
  100. package/dist/paywall/contract.js +3 -0
  101. package/dist/paywall/contract.js.map +1 -0
  102. package/dist/paywall/index.d.ts +14 -0
  103. package/dist/paywall/index.d.ts.map +1 -0
  104. package/dist/paywall/index.js +19 -0
  105. package/dist/paywall/index.js.map +1 -0
  106. package/dist/paywall/observer.d.ts +38 -0
  107. package/dist/paywall/observer.d.ts.map +1 -0
  108. package/dist/paywall/observer.js +6 -0
  109. package/dist/paywall/observer.js.map +1 -0
  110. package/dist/paywall/state.d.ts +26 -0
  111. package/dist/paywall/state.d.ts.map +1 -0
  112. package/dist/paywall/state.js +132 -0
  113. package/dist/paywall/state.js.map +1 -0
  114. package/dist/paywall/tracker.d.ts +36 -0
  115. package/dist/paywall/tracker.d.ts.map +1 -0
  116. package/dist/paywall/tracker.js +180 -0
  117. package/dist/paywall/tracker.js.map +1 -0
  118. package/dist/version.d.ts +3 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +6 -0
  121. package/dist/version.js.map +1 -0
  122. package/docs/onboarding-run-contract.md +1013 -0
  123. package/docs/onboarding-run.examples.ts +306 -0
  124. package/docs/onboarding-run.schema.json +224 -0
  125. package/docs/onboarding-run.types.ts +144 -0
  126. package/docs/paywall-presentation-contract.md +139 -0
  127. package/docs/paywall-presentation.examples.ts +42 -0
  128. package/docs/paywall-presentation.schema.json +268 -0
  129. package/docs/paywall-presentation.types.ts +73 -0
  130. package/onboarding/package.json +4 -0
  131. package/package.json +120 -4
  132. package/paywall/package.json +1 -0
  133. package/src/client/client.ts +417 -0
  134. package/src/client/index.ts +54 -0
  135. package/src/client/native.ts +109 -0
  136. package/src/client/onboardingSink.ts +121 -0
  137. package/src/client/paywallSink.ts +25 -0
  138. package/src/client/requests.ts +126 -0
  139. package/src/client/types.ts +198 -0
  140. package/src/core/context.ts +69 -0
  141. package/src/core/delivery.ts +181 -0
  142. package/src/core/diagnostics.ts +30 -0
  143. package/src/core/httpSink.ts +70 -0
  144. package/src/core/ids.ts +46 -0
  145. package/src/core/index.ts +20 -0
  146. package/src/core/sink.ts +46 -0
  147. package/src/core/storage.ts +112 -0
  148. package/src/core/text.ts +37 -0
  149. package/src/core/time.ts +26 -0
  150. package/src/onboarding/contract.ts +144 -0
  151. package/src/onboarding/index.ts +39 -0
  152. package/src/onboarding/input.ts +246 -0
  153. package/src/onboarding/persisted.ts +93 -0
  154. package/src/onboarding/state.ts +233 -0
  155. package/src/onboarding/tracker.ts +855 -0
  156. package/src/paywall/contract.ts +73 -0
  157. package/src/paywall/index.ts +36 -0
  158. package/src/paywall/observer.ts +43 -0
  159. package/src/paywall/state.ts +125 -0
  160. package/src/paywall/tracker.ts +200 -0
  161. package/src/version.ts +2 -0
@@ -0,0 +1,181 @@
1
+ import { normalizeResult, type Sink, type SinkResult } from "./sink";
2
+ import type { Timers } from "./time";
3
+
4
+ /** One send: a full snapshot and the `seq` it carries. A retry reuses both, unchanged. */
5
+ export interface Outbound<T> {
6
+ seq: number;
7
+ body: T;
8
+ }
9
+
10
+ export interface RetryPolicy {
11
+ /** Delay before the first retry. Default 1,000 ms. */
12
+ initialDelayMs?: number;
13
+ /** Multiplier per further retry. Default 2. */
14
+ factor?: number;
15
+ /** Ceiling on the delay. Default 60,000 ms. */
16
+ maxDelayMs?: number;
17
+ }
18
+
19
+ export interface DeliveryOptions<T> {
20
+ sink: Sink<T>;
21
+ timers: Timers;
22
+ retry?: RetryPolicy;
23
+ /** A send that has not answered after this long counts as transient. Default 30,000 ms. */
24
+ attemptTimeoutMs?: number;
25
+ /** Called once a snapshot is settled for good: accepted, ignored or rejected. */
26
+ onSettled?(item: Outbound<T>, result: SinkResult): void;
27
+ /** Called whenever the pending snapshot changes, so it can be persisted. */
28
+ onPendingChange?(pending: Outbound<T> | null): void;
29
+ }
30
+
31
+ export interface Delivery<T> {
32
+ /**
33
+ * Hands over a snapshot. It replaces any pending one with a lower `seq`: full
34
+ * snapshots supersede each other, so only the latest needs to arrive.
35
+ */
36
+ enqueue(item: Outbound<T>): void;
37
+ /** Sends the pending snapshot now, without waiting out a backoff. */
38
+ flush(): void;
39
+ /** The snapshot not yet settled, if any. */
40
+ pending(): Outbound<T> | null;
41
+ /** Stops all timers. The pending snapshot is kept. */
42
+ stop(): void;
43
+ /**
44
+ * Winds down: skips any backoff to give the pending snapshot (and any
45
+ * handed over later) one last attempt each, and never schedules a retry.
46
+ * A snapshot that still fails stays pending, so it is not lost.
47
+ */
48
+ close(): void;
49
+ /**
50
+ * Resolves once no attempt is in flight. After `close()` nothing is retried,
51
+ * so this is when the last attempts are over, and `pending()` is what the
52
+ * sink did not take.
53
+ */
54
+ idle(): Promise<void>;
55
+ }
56
+
57
+ /**
58
+ * Delivers the latest snapshot of one run through a sink, one send at a time.
59
+ *
60
+ * - Only `accepted`, `ignored` and `rejected` settle a snapshot. Everything
61
+ * else (a throw, a timeout, a result without an outcome) is transient and
62
+ * the same snapshot is sent again after an exponential backoff.
63
+ * - A settled `seq` is never sent again, so a rejected snapshot is never
64
+ * resent unchanged.
65
+ * - Nothing here throws into the caller.
66
+ */
67
+ export function createDelivery<T>(options: DeliveryOptions<T>): Delivery<T> {
68
+ const { sink, timers } = options;
69
+ const initial = options.retry?.initialDelayMs ?? 1000;
70
+ const factor = options.retry?.factor ?? 2;
71
+ const max = options.retry?.maxDelayMs ?? 60_000;
72
+ const timeoutMs = options.attemptTimeoutMs ?? 30_000;
73
+
74
+ let pending: Outbound<T> | null = null;
75
+ let inFlight: Outbound<T> | null = null;
76
+ let settledSeq = 0;
77
+ let failures = 0;
78
+ let retryTimer: unknown = null;
79
+ let stopped = false;
80
+ let closed = false;
81
+ let idleWaiters: (() => void)[] = [];
82
+
83
+ const safe = (fn: () => void) => {
84
+ try {
85
+ fn();
86
+ } catch {
87
+ // A host callback that throws must not break delivery.
88
+ }
89
+ };
90
+ const setPending = (next: Outbound<T> | null) => {
91
+ pending = next;
92
+ safe(() => options.onPendingChange?.(next));
93
+ };
94
+ const clearRetry = () => {
95
+ if (retryTimer !== null) timers.clearTimeout(retryTimer);
96
+ retryTimer = null;
97
+ };
98
+
99
+ const callSink = (item: Outbound<T>): Promise<SinkResult> =>
100
+ new Promise<SinkResult>((resolve) => {
101
+ const timer = timers.setTimeout(() => resolve({ outcome: "transient", reason: "timeout" }), timeoutMs);
102
+ const done = (r: SinkResult) => {
103
+ timers.clearTimeout(timer);
104
+ resolve(r);
105
+ };
106
+ try {
107
+ Promise.resolve(sink.send(item.body)).then(
108
+ (value) => done(normalizeResult(value)),
109
+ (error) => done({ outcome: "transient", reason: String(error) }),
110
+ );
111
+ } catch (error) {
112
+ done({ outcome: "transient", reason: String(error) });
113
+ }
114
+ });
115
+
116
+ const wakeIdle = () => {
117
+ if (inFlight) return;
118
+ const waiters = idleWaiters;
119
+ idleWaiters = [];
120
+ for (const resolve of waiters) resolve();
121
+ };
122
+
123
+ const attempt = () => {
124
+ if (stopped || inFlight || !pending) return;
125
+ clearRetry();
126
+ const item = pending;
127
+ inFlight = item;
128
+ void callSink(item).then((result) => {
129
+ inFlight = null;
130
+ if (result.outcome === "transient") {
131
+ if (closed) {
132
+ // No retry after close; a newer snapshot handed over meanwhile still gets its one attempt.
133
+ if (pending && pending.seq > item.seq) attempt();
134
+ return wakeIdle();
135
+ }
136
+ failures += 1;
137
+ const delay = Math.min(max, initial * Math.pow(factor, failures - 1));
138
+ if (!stopped) retryTimer = timers.setTimeout(() => {
139
+ retryTimer = null;
140
+ attempt();
141
+ }, delay);
142
+ return wakeIdle();
143
+ }
144
+ failures = 0;
145
+ settledSeq = Math.max(settledSeq, item.seq);
146
+ if (pending && pending.seq <= item.seq) setPending(null);
147
+ safe(() => options.onSettled?.(item, result));
148
+ attempt(); // a newer snapshot may have arrived meanwhile
149
+ wakeIdle();
150
+ });
151
+ };
152
+
153
+ return {
154
+ enqueue(item) {
155
+ if (item.seq <= settledSeq) return;
156
+ if (pending && item.seq <= pending.seq) return;
157
+ setPending(item);
158
+ if (retryTimer === null) attempt();
159
+ },
160
+ flush() {
161
+ clearRetry();
162
+ attempt();
163
+ },
164
+ pending: () => pending,
165
+ stop() {
166
+ stopped = true;
167
+ clearRetry();
168
+ },
169
+ close() {
170
+ if (closed) return;
171
+ closed = true;
172
+ clearRetry();
173
+ attempt();
174
+ },
175
+ idle: () =>
176
+ new Promise<void>((resolve) => {
177
+ idleWaiters.push(resolve);
178
+ wakeIdle();
179
+ }),
180
+ };
181
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Something the SDK chose not to do, or could not do, reported instead of
3
+ * thrown: tracking must never break the app it runs in.
4
+ */
5
+ export interface Diagnostic {
6
+ /** Stable machine-readable code, e.g. `unknown-step`, `rejected`, `truncated`. */
7
+ code: string;
8
+ message: string;
9
+ runId?: string;
10
+ }
11
+
12
+ export type DiagnosticHandler = (diagnostic: Diagnostic) => void;
13
+
14
+ /** The default handler: a console warning, when a console exists. */
15
+ export const consoleDiagnostics: DiagnosticHandler = (d) => {
16
+ const c = (globalThis as { console?: { warn?: (...a: unknown[]) => void } }).console;
17
+ c?.warn?.(`[rocalytics-sdk] ${d.code}: ${d.message}`);
18
+ };
19
+
20
+ /** Wraps a host handler so that a handler which throws cannot break the caller. */
21
+ export function safeDiagnostics(handler: DiagnosticHandler | undefined): DiagnosticHandler {
22
+ const h = handler ?? consoleDiagnostics;
23
+ return (d) => {
24
+ try {
25
+ h(d);
26
+ } catch {
27
+ // ignored on purpose
28
+ }
29
+ };
30
+ }
@@ -0,0 +1,70 @@
1
+ import { normalizeResult, type Sink, type SinkResult } from "./sink";
2
+
3
+ type Headers = Record<string, string>;
4
+
5
+ export interface HttpSinkOptions {
6
+ /** The collector endpoint. Each payload is POSTed to it as JSON. */
7
+ url: string;
8
+ /** Extra request headers, such as an authorization token. A function is called for every send. */
9
+ headers?: Headers | (() => Headers | Promise<Headers>);
10
+ /** Defaults to the global `fetch`. */
11
+ fetch?: (url: string, init: { method: string; headers: Headers; body: string; signal?: unknown }) => Promise<{
12
+ text(): Promise<string>;
13
+ }>;
14
+ /** Gives up on a request after this long; the send is then transient. Default 15,000 ms. */
15
+ timeoutMs?: number;
16
+ }
17
+
18
+ /**
19
+ * A sink that POSTs each payload to an HTTP collector.
20
+ *
21
+ * The outcome comes from the response BODY only: `{"outcome": "accepted" |
22
+ * "ignored" | "rejected"}`. The status code is not read, because a proxy or
23
+ * gateway can answer 401, 404 or 413 without having looked at the payload, so
24
+ * any response without an outcome, and no response at all, is transient.
25
+ * It never throws.
26
+ *
27
+ * Its `destination` is `url`, marked ` (custom fetch)` when a `fetch` is
28
+ * passed: that one may never reach `url` (a test double), so it never counts
29
+ * as the same destination as the real one. Headers are not part of it.
30
+ */
31
+ export function createHttpSink(options: HttpSinkOptions): Sink<unknown> {
32
+ const timeoutMs = options.timeoutMs ?? 15_000;
33
+ return {
34
+ destination: options.fetch ? `${options.url} (custom fetch)` : options.url,
35
+ async send(payload): Promise<SinkResult> {
36
+ const doFetch = options.fetch ?? (globalThis.fetch as unknown as HttpSinkOptions["fetch"]);
37
+ if (!doFetch) return { outcome: "transient", reason: "no fetch available" };
38
+ const Abort = (globalThis as { AbortController?: new () => { signal: unknown; abort(): void } }).AbortController;
39
+ const controller = Abort ? new Abort() : null;
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ try {
42
+ const extra = typeof options.headers === "function" ? await options.headers() : options.headers;
43
+ const request = doFetch(options.url, {
44
+ method: "POST",
45
+ headers: { ...extra, "content-type": "application/json" },
46
+ body: JSON.stringify(payload),
47
+ signal: controller?.signal,
48
+ }).then((res) => res.text());
49
+ const timeout = new Promise<never>((_, reject) => {
50
+ timer = setTimeout(() => {
51
+ controller?.abort();
52
+ reject(new Error("timeout"));
53
+ }, timeoutMs);
54
+ });
55
+ const text = await Promise.race([request, timeout]);
56
+ let body: unknown = undefined;
57
+ try {
58
+ body = JSON.parse(text);
59
+ } catch {
60
+ // Not JSON: no outcome, so transient.
61
+ }
62
+ return normalizeResult(body);
63
+ } catch (error) {
64
+ return { outcome: "transient", reason: String(error) };
65
+ } finally {
66
+ if (timer !== undefined) clearTimeout(timer);
67
+ }
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,46 @@
1
+ import type { Clock } from "./time";
2
+
3
+ const hex = (bytes: Uint8Array, from: number, to: number) => {
4
+ let out = "";
5
+ for (let i = from; i < to; i++) out += (bytes[i] + 0x100).toString(16).slice(1);
6
+ return out;
7
+ };
8
+
9
+ /**
10
+ * A lowercase UUIDv7 (RFC 9562): 48 bits of millisecond time, then random
11
+ * bits. It sorts by creation time, which is why the contract recommends it.
12
+ * `random` must hold at least 16 bytes; only the bits after the time are used.
13
+ */
14
+ export function uuidv7(nowMs: number, random: Uint8Array): string {
15
+ const b = new Uint8Array(16);
16
+ for (let i = 0; i < 16; i++) b[i] = random[i] ?? 0;
17
+ let t = Math.max(0, Math.floor(nowMs));
18
+ for (let i = 5; i >= 0; i--) {
19
+ b[i] = t % 256;
20
+ t = Math.floor(t / 256);
21
+ }
22
+ b[6] = 0x70 | (b[6] & 0x0f); // version 7
23
+ b[8] = 0x80 | (b[8] & 0x3f); // variant 10xx
24
+ return `${hex(b, 0, 4)}-${hex(b, 4, 6)}-${hex(b, 6, 8)}-${hex(b, 8, 10)}-${hex(b, 10, 16)}`;
25
+ }
26
+
27
+ /**
28
+ * 16 random bytes from `crypto.getRandomValues` when the runtime has it, and
29
+ * from `Math.random` otherwise. A run id needs to be unique, not secret, so
30
+ * the fallback is acceptable on a runtime with no crypto global.
31
+ */
32
+ export function randomBytes16(): Uint8Array {
33
+ const out = new Uint8Array(16);
34
+ const c = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;
35
+ if (c && typeof c.getRandomValues === "function") {
36
+ c.getRandomValues(out);
37
+ return out;
38
+ }
39
+ for (let i = 0; i < 16; i++) out[i] = Math.floor(Math.random() * 256);
40
+ return out;
41
+ }
42
+
43
+ /** The default id minter: UUIDv7 from the injected clock. */
44
+ export function createUuid(clock: Clock): () => string {
45
+ return () => uuidv7(clock.now(), randomBytes16());
46
+ }
@@ -0,0 +1,20 @@
1
+ // @rocapine/rocalytics-sdk/core: shared building blocks for the surface subpaths.
2
+ // Internal: exported so a custom sink or another surface can use it, with no
3
+ // stability promise beyond what /onboarding re-exports.
4
+
5
+ export { captureRunContext } from "./context";
6
+ export type { RunContextInput, WireRunContext } from "./context";
7
+ export { consoleDiagnostics, safeDiagnostics } from "./diagnostics";
8
+ export type { Diagnostic, DiagnosticHandler } from "./diagnostics";
9
+ export { createDelivery } from "./delivery";
10
+ export type { Delivery, DeliveryOptions, Outbound, RetryPolicy } from "./delivery";
11
+ export { createHttpSink } from "./httpSink";
12
+ export type { HttpSinkOptions } from "./httpSink";
13
+ export { createUuid, randomBytes16, uuidv7 } from "./ids";
14
+ export { normalizeResult } from "./sink";
15
+ export type { Sink, SinkOutcome, SinkResult } from "./sink";
16
+ export { createSerialStore, memoryStorage } from "./storage";
17
+ export type { KeyValueStorage, SerialStore } from "./storage";
18
+ export { codePointLength, utf8ByteLength } from "./text";
19
+ export { systemClock, systemTimers, toTimestamp } from "./time";
20
+ export type { Clock, Timers } from "./time";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The three outcomes an ingest can give a snapshot, carried in its response
3
+ * body. Anything else (no response, an error, a response without one of these)
4
+ * is transient: the snapshot is kept and sent again.
5
+ */
6
+ export type SinkOutcome = "accepted" | "ignored" | "rejected";
7
+
8
+ export type SinkResult =
9
+ | { outcome: SinkOutcome; reason?: string }
10
+ | { outcome: "transient"; reason?: string };
11
+
12
+ /**
13
+ * Delivers one payload somewhere. A sink reports what happened; it does not
14
+ * retry, and it may throw: the caller treats a throw as transient.
15
+ *
16
+ * The caller sends one payload of a run at a time, and counts a send with no
17
+ * answer after its attempt timeout (30 s by default) as transient. A sink that
18
+ * keeps working past that timeout can therefore overlap with the retry. Both
19
+ * carry the same seq and body, so this is safe against an ingest that ignores
20
+ * an equal seq, but a sink should give up before the attempt timeout (the
21
+ * stock HTTP sink gives up after 15 s).
22
+ */
23
+ export interface Sink<T = unknown> {
24
+ /**
25
+ * Where payloads go, such as the collector's URL. Two sinks with the same
26
+ * destination are interchangeable: a reconfigure hands the old tracker's
27
+ * unsent snapshots to the new one, and stored ones are sent by either.
28
+ * Without it, only the same sink object counts as the same destination.
29
+ * A test double must not claim a real destination: the stock sinks mark
30
+ * theirs ` (custom fetch)` when given a `fetch`. Headers are not part of it.
31
+ */
32
+ readonly destination?: string;
33
+ send(payload: T): Promise<SinkResult> | SinkResult;
34
+ }
35
+
36
+ /** Reads a sink's answer strictly: only the three named outcomes end a send. */
37
+ export function normalizeResult(value: unknown): SinkResult {
38
+ if (value && typeof value === "object") {
39
+ const { outcome, reason } = value as { outcome?: unknown; reason?: unknown };
40
+ if (outcome === "accepted" || outcome === "ignored" || outcome === "rejected") {
41
+ return typeof reason === "string" ? { outcome, reason } : { outcome };
42
+ }
43
+ if (typeof reason === "string") return { outcome: "transient", reason };
44
+ }
45
+ return { outcome: "transient" };
46
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A string key-value store. The shape matches `@react-native-async-storage/async-storage`
3
+ * and `localStorage`, so either can be passed as is; this package depends on neither.
4
+ */
5
+ export interface KeyValueStorage {
6
+ getItem(key: string): string | null | Promise<string | null>;
7
+ setItem(key: string, value: string): void | Promise<void>;
8
+ removeItem(key: string): void | Promise<void>;
9
+ }
10
+
11
+ /** An in-memory `KeyValueStorage`. Survives nothing; useful for tests and for opting out of persistence. */
12
+ export function memoryStorage(): KeyValueStorage & { dump(): Record<string, string> } {
13
+ const map = new Map<string, string>();
14
+ return {
15
+ getItem: (k) => map.get(k) ?? null,
16
+ setItem: (k, v) => void map.set(k, v),
17
+ removeItem: (k) => void map.delete(k),
18
+ dump: () => Object.fromEntries(map),
19
+ };
20
+ }
21
+
22
+ /** What a read found: a value (or nothing stored), a value that is not JSON, or no answer from storage at all. */
23
+ export type ReadResult<T> =
24
+ | { status: "ok"; value: T | null }
25
+ | { status: "invalid"; error: unknown }
26
+ | { status: "failed"; error: unknown };
27
+
28
+ export interface SerialStore<T> {
29
+ /** Reads the value, after every write queued before it. Null when absent or unreadable. */
30
+ load(): Promise<T | null>;
31
+ /**
32
+ * Reads the value, after every write queued before it, telling a value that
33
+ * is not JSON (`invalid`) from storage that did not answer (`failed`). A
34
+ * failed read is retried once, in the same turn, before it counts as failed.
35
+ * `waitFirst` is awaited inside that turn, before reading: operations queued
36
+ * after this read still wait for it, so it can be ordered behind work done
37
+ * elsewhere (another store's last writes) without them overtaking it.
38
+ */
39
+ read(waitFirst?: Promise<unknown>): Promise<ReadResult<T>>;
40
+ /** Queues a write (null removes the key). Never throws; failures go to `onError`. */
41
+ save(value: T | null): void;
42
+ /**
43
+ * Queues a write whose value is computed when its turn comes, so it is the
44
+ * latest one; `undefined` skips the write. Resolves once done. Never rejects.
45
+ */
46
+ saveWith(produce: () => T | null | undefined | Promise<T | null | undefined>): Promise<void>;
47
+ /** Resolves once every queued operation has finished. */
48
+ idle(): Promise<void>;
49
+ }
50
+
51
+ /**
52
+ * JSON values under one key, with every read and write applied in call order,
53
+ * so an older write can never land after a newer one.
54
+ */
55
+ export function createSerialStore<T>(
56
+ storage: KeyValueStorage,
57
+ key: string,
58
+ onError: (error: unknown) => void,
59
+ ): SerialStore<T> {
60
+ const queue: { chain: Promise<unknown> } = { chain: Promise.resolve() };
61
+ const enqueue = <R>(op: () => R | Promise<R>, fallback: R): Promise<R> => {
62
+ const next = queue.chain.then(op).catch((e) => {
63
+ onError(e);
64
+ return fallback;
65
+ });
66
+ queue.chain = next;
67
+ return next;
68
+ };
69
+ const getRaw = async (): Promise<string | null> => {
70
+ try {
71
+ return await storage.getItem(key);
72
+ } catch {
73
+ return await storage.getItem(key); // one retry, still inside this turn of the queue
74
+ }
75
+ };
76
+ const read = (waitFirst?: Promise<unknown>): Promise<ReadResult<T>> =>
77
+ enqueue<ReadResult<T>>(async () => {
78
+ if (waitFirst) await waitFirst.catch(() => undefined);
79
+ let raw: string | null;
80
+ try {
81
+ raw = await getRaw();
82
+ } catch (error) {
83
+ return { status: "failed", error };
84
+ }
85
+ if (raw == null) return { status: "ok", value: null };
86
+ try {
87
+ return { status: "ok", value: JSON.parse(raw) as T };
88
+ } catch (error) {
89
+ return { status: "invalid", error };
90
+ }
91
+ }, { status: "failed", error: undefined });
92
+ const write = async (value: T | null) => {
93
+ if (value === null) await storage.removeItem(key);
94
+ else await storage.setItem(key, JSON.stringify(value));
95
+ };
96
+ return {
97
+ load: () =>
98
+ read().then((r) => {
99
+ if (r.status === "ok") return r.value;
100
+ onError(r.error);
101
+ return null;
102
+ }),
103
+ read,
104
+ save: (value) => void enqueue(() => write(value), undefined),
105
+ saveWith: (produce) =>
106
+ enqueue(async () => {
107
+ const value = await produce();
108
+ if (value !== undefined) await write(value);
109
+ }, undefined),
110
+ idle: () => enqueue(() => undefined, undefined),
111
+ };
112
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * UTF-8 length of `s` in bytes, without `TextEncoder` or `Buffer` (neither is
3
+ * guaranteed on every JavaScript runtime an app ships on). A lone surrogate
4
+ * counts as the 3-byte replacement character, as an encoder would write it.
5
+ */
6
+ export function utf8ByteLength(s: string): number {
7
+ let bytes = 0;
8
+ for (let i = 0; i < s.length; i++) {
9
+ const c = s.charCodeAt(i);
10
+ if (c < 0x80) bytes += 1;
11
+ else if (c < 0x800) bytes += 2;
12
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
13
+ const next = s.charCodeAt(i + 1);
14
+ if (next >= 0xdc00 && next <= 0xdfff) {
15
+ bytes += 4;
16
+ i++;
17
+ } else {
18
+ bytes += 3;
19
+ }
20
+ } else bytes += 3;
21
+ }
22
+ return bytes;
23
+ }
24
+
25
+ /** Length in code points: what JSON Schema's `minLength` / `maxLength` count. */
26
+ export function codePointLength(s: string): number {
27
+ let n = 0;
28
+ for (let i = 0; i < s.length; i++) {
29
+ const c = s.charCodeAt(i);
30
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
31
+ const next = s.charCodeAt(i + 1);
32
+ if (next >= 0xdc00 && next <= 0xdfff) i++;
33
+ }
34
+ n++;
35
+ }
36
+ return n;
37
+ }
@@ -0,0 +1,26 @@
1
+ /** A source of the current time, in milliseconds since the epoch. Injected so tests can fix it. */
2
+ export interface Clock {
3
+ now(): number;
4
+ }
5
+
6
+ /** The two timer functions the core uses. Injected so tests can drive time. */
7
+ export interface Timers {
8
+ setTimeout(fn: () => void, ms: number): unknown;
9
+ clearTimeout(handle: unknown): void;
10
+ }
11
+
12
+ export const systemClock: Clock = { now: () => Date.now() };
13
+
14
+ export const systemTimers: Timers = {
15
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
16
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
17
+ };
18
+
19
+ /**
20
+ * A UTC timestamp with exactly three fractional digits and a `Z`, e.g.
21
+ * `2026-01-10T09:00:00.000Z`: the only timestamp form the contract accepts.
22
+ * `toISOString` produces exactly that for every year from 0 to 9999.
23
+ */
24
+ export function toTimestamp(ms: number): string {
25
+ return new Date(ms).toISOString();
26
+ }