@reservamos/browser-analytics 1.1.8-alpha.0 → 1.1.8-alpha.1

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.
@@ -0,0 +1,164 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createPendingWorkQueue } from './pendingWork';
3
+
4
+ describe('bounded pending work', () => {
5
+ let ready: boolean;
6
+ let signal: () => void;
7
+ let detach: ReturnType<typeof vi.fn>;
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ ready = false;
11
+ signal = () => undefined;
12
+ detach = vi.fn();
13
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
14
+ });
15
+ afterEach(() => {
16
+ vi.clearAllTimers();
17
+ vi.useRealTimers();
18
+ vi.restoreAllMocks();
19
+ });
20
+ function queue(maxActive = 1) {
21
+ return createPendingWorkQueue({
22
+ isReady: () => ready,
23
+ subscribe: (notify) => {
24
+ signal = notify;
25
+ return detach;
26
+ },
27
+ maxEntries: 2,
28
+ maxBytes: 10,
29
+ maxActive,
30
+ lifetime: 60000,
31
+ wait: 5000,
32
+ stage: 'readiness',
33
+ });
34
+ }
35
+ const work = (run: () => void | Promise<void>, bytes = 1) => ({
36
+ eventName: 'Test',
37
+ bytes,
38
+ isCurrent: () => true,
39
+ run,
40
+ });
41
+
42
+ it('resolves the foreground wait but retains unsent work for readiness at 5.2 seconds', async () => {
43
+ const run = vi.fn();
44
+ const pending = queue().enqueue(work(run));
45
+ await vi.advanceTimersByTimeAsync(5000);
46
+ await expect(pending).resolves.toBeUndefined();
47
+ expect(run).not.toHaveBeenCalled();
48
+ await vi.advanceTimersByTimeAsync(200);
49
+ ready = true;
50
+ signal();
51
+ signal();
52
+ await vi.advanceTimersByTimeAsync(0);
53
+ expect(run).toHaveBeenCalledOnce();
54
+ expect(detach).toHaveBeenCalledOnce();
55
+ expect(vi.getTimerCount()).toBe(0);
56
+ });
57
+
58
+ it('never retries after a provider accepts and throws, including reentrant readiness', async () => {
59
+ const q = queue();
60
+ const run = vi.fn(() => {
61
+ signal();
62
+ throw new Error('accepted then threw');
63
+ });
64
+ const pending = q.enqueue(work(run));
65
+ ready = true;
66
+ signal();
67
+ signal();
68
+ await pending;
69
+ await vi.advanceTimersByTimeAsync(60000);
70
+ signal();
71
+ expect(run).toHaveBeenCalledOnce();
72
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(
73
+ 'accepted then threw',
74
+ );
75
+ });
76
+
77
+ it('keeps separate legitimate equal-payload calls', async () => {
78
+ const q = queue(2);
79
+ const run = vi.fn();
80
+ const first = q.enqueue(work(run));
81
+ const second = q.enqueue(work(run));
82
+ ready = true;
83
+ signal();
84
+ await Promise.all([first, second]);
85
+ expect(run).toHaveBeenCalledTimes(2);
86
+ });
87
+
88
+ it('expires without submission and cleans up its subscription and timer', async () => {
89
+ const run = vi.fn();
90
+ const pending = queue().enqueue(work(run));
91
+ await vi.advanceTimersByTimeAsync(60000);
92
+ await pending;
93
+ ready = true;
94
+ signal();
95
+ expect(run).not.toHaveBeenCalled();
96
+ expect(detach).toHaveBeenCalledOnce();
97
+ expect(vi.getTimerCount()).toBe(0);
98
+ });
99
+
100
+ it('evicts the oldest waiting work at capacity and rejects oversized work', async () => {
101
+ const q = queue();
102
+ const runs = [vi.fn(), vi.fn(), vi.fn(), vi.fn()];
103
+ const promises = runs.map((run, i) =>
104
+ q.enqueue(work(run, i === 3 ? 11 : 4)),
105
+ );
106
+ ready = true;
107
+ signal();
108
+ await Promise.all(promises);
109
+ expect(runs[0]).not.toHaveBeenCalled();
110
+ expect(runs[1]).toHaveBeenCalledOnce();
111
+ expect(runs[2]).toHaveBeenCalledOnce();
112
+ expect(runs[3]).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('invalidates waiting work before identity changes can submit it', async () => {
116
+ let current = true;
117
+ const run = vi.fn();
118
+ const pending = queue().enqueue({ ...work(run), isCurrent: () => current });
119
+ current = false;
120
+ signal();
121
+ await pending;
122
+ ready = true;
123
+ signal();
124
+ expect(run).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('enforces the byte budget before the entry-count budget is reached', async () => {
128
+ const q = queue();
129
+ const first = vi.fn();
130
+ const second = vi.fn();
131
+ const old = q.enqueue(work(first, 6));
132
+ const retained = q.enqueue(work(second, 6));
133
+ ready = true;
134
+ signal();
135
+ await Promise.all([old, retained]);
136
+ expect(first).not.toHaveBeenCalled();
137
+ expect(second).toHaveBeenCalledOnce();
138
+ });
139
+
140
+ it('suppresses expired active effects without exceeding actual provider concurrency', async () => {
141
+ const q = queue();
142
+ ready = true;
143
+ let finish!: () => void;
144
+ let active!: () => boolean;
145
+ const first = q.enqueue({
146
+ ...work(() => undefined),
147
+ run: (isActive) => {
148
+ active = isActive;
149
+ return new Promise<void>((resolve) => {
150
+ finish = resolve;
151
+ });
152
+ },
153
+ });
154
+ await vi.advanceTimersByTimeAsync(60000);
155
+ await first;
156
+ expect(active()).toBe(false);
157
+ const run = vi.fn();
158
+ const second = q.enqueue(work(run));
159
+ expect(run).not.toHaveBeenCalled();
160
+ finish();
161
+ await second;
162
+ expect(run).toHaveBeenCalledOnce();
163
+ });
164
+ });
@@ -0,0 +1,148 @@
1
+ interface PendingWork {
2
+ eventName: string;
3
+ bytes: number;
4
+ isCurrent: () => boolean;
5
+ run: (isActive: () => boolean) => void | Promise<void>;
6
+ }
7
+
8
+ interface QueueOptions {
9
+ isReady: () => boolean;
10
+ subscribe: (notify: () => void) => () => void;
11
+ maxEntries: number;
12
+ maxBytes: number;
13
+ maxActive: number;
14
+ lifetime: number;
15
+ wait: number;
16
+ stage: 'readiness' | 'fingerprint';
17
+ }
18
+
19
+ interface Entry extends PendingWork {
20
+ state: 'waiting' | 'active' | 'closed';
21
+ deadline: number;
22
+ foregroundDeadline: number;
23
+ resolve?: () => void;
24
+ }
25
+
26
+ /** Retains bounded local work and claims each entry once, before invoking it. */
27
+ export function createPendingWorkQueue(options: QueueOptions) {
28
+ const entries = new Set<Entry>();
29
+ let bytes = 0;
30
+ let active = 0;
31
+ let draining = false;
32
+ let unsubscribe: (() => void) | undefined;
33
+ let timer: ReturnType<typeof setTimeout> | undefined;
34
+
35
+ function diagnostic(entry: PendingWork, reason: string): void {
36
+ console.error('Tracking recovery diagnostic', {
37
+ eventName: entry.eventName,
38
+ stage: options.stage,
39
+ reason,
40
+ });
41
+ }
42
+
43
+ function resolveForeground(entry: Entry): void {
44
+ entry.resolve?.();
45
+ entry.resolve = undefined;
46
+ }
47
+
48
+ function close(entry: Entry, reason?: string): void {
49
+ if (entry.state === 'closed') return;
50
+ entry.state = 'closed';
51
+ entries.delete(entry);
52
+ bytes -= entry.bytes;
53
+ resolveForeground(entry);
54
+ if (reason) diagnostic(entry, reason);
55
+ }
56
+
57
+ function schedule(): void {
58
+ clearTimeout(timer);
59
+ timer = undefined;
60
+ if (entries.size === 0) {
61
+ unsubscribe?.();
62
+ unsubscribe = undefined;
63
+ return;
64
+ }
65
+ let next = Infinity;
66
+ for (const entry of entries) {
67
+ next = Math.min(next, entry.deadline);
68
+ if (entry.resolve) next = Math.min(next, entry.foregroundDeadline);
69
+ }
70
+ timer = setTimeout(drain, Math.max(0, next - Date.now()));
71
+ }
72
+
73
+ function drain(): void {
74
+ if (draining) return;
75
+ draining = true;
76
+ try {
77
+ for (const entry of entries) {
78
+ if (!entry.isCurrent()) close(entry, 'identity_changed');
79
+ else if (Date.now() >= entry.deadline) close(entry, 'timeout');
80
+ else if (entry.resolve && Date.now() >= entry.foregroundDeadline) {
81
+ resolveForeground(entry);
82
+ }
83
+ }
84
+ for (const entry of entries) {
85
+ if (active >= options.maxActive || !options.isReady()) break;
86
+ if (entry.state !== 'waiting') continue;
87
+ // No await before this claim: reentrant or repeated signals cannot replay it.
88
+ entry.state = 'active';
89
+ active++;
90
+ const isActive = () =>
91
+ entry.state === 'active' &&
92
+ Date.now() < entry.deadline &&
93
+ entry.isCurrent();
94
+ const finish = (failed = false) => {
95
+ if (failed && isActive()) diagnostic(entry, 'failed');
96
+ close(entry);
97
+ // An expired remote call still occupies its physical concurrency slot.
98
+ active--;
99
+ drain();
100
+ };
101
+ try {
102
+ void Promise.resolve(entry.run(isActive)).then(
103
+ () => finish(),
104
+ () => finish(true),
105
+ );
106
+ } catch {
107
+ finish(true);
108
+ }
109
+ }
110
+ } finally {
111
+ draining = false;
112
+ schedule();
113
+ }
114
+ }
115
+
116
+ function enqueue(work: PendingWork): Promise<void> {
117
+ if (work.bytes > options.maxBytes) {
118
+ diagnostic(work, 'capacity');
119
+ return Promise.resolve();
120
+ }
121
+ while (
122
+ entries.size >= options.maxEntries ||
123
+ bytes + work.bytes > options.maxBytes
124
+ ) {
125
+ const oldest = [...entries].find((entry) => entry.state === 'waiting');
126
+ if (!oldest) {
127
+ diagnostic(work, 'capacity');
128
+ return Promise.resolve();
129
+ }
130
+ close(oldest, 'capacity');
131
+ }
132
+ return new Promise((resolve) => {
133
+ const entry: Entry = {
134
+ ...work,
135
+ state: 'waiting',
136
+ deadline: Date.now() + options.lifetime,
137
+ foregroundDeadline: Date.now() + options.wait,
138
+ resolve,
139
+ };
140
+ entries.add(entry);
141
+ bytes += entry.bytes;
142
+ unsubscribe ??= options.subscribe(drain);
143
+ drain();
144
+ });
145
+ }
146
+
147
+ return { enqueue };
148
+ }
@@ -0,0 +1,15 @@
1
+ /** Copies serializable event structures without replacing array undefined values with null. */
2
+ export function snapshotEventData<T>(value: T): T {
3
+ if (Array.isArray(value))
4
+ return value.map((item: unknown) => snapshotEventData(item)) as T;
5
+ if (value instanceof Date) return new Date(value.getTime()) as T;
6
+ if (value !== null && typeof value === 'object') {
7
+ return Object.fromEntries(
8
+ Object.entries(value).map(([key, item]) => [
9
+ key,
10
+ snapshotEventData(item),
11
+ ]),
12
+ ) as T;
13
+ }
14
+ return value;
15
+ }