@solhun/feedback-kit-core 0.1.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.
package/src/queue.ts ADDED
@@ -0,0 +1,546 @@
1
+ // 영속 제출 큐 — 저장소(FeedbackStorage)에 제보를 쌓고, 어댑터로 비운다.
2
+ //
3
+ // 이 모듈이 지키는 약속(DP-252 전송 보장):
4
+ // 1. 제보는 **전송 전에** 큐에 들어간다. 큐에서 빠지는 유일한 조건은 어댑터가
5
+ // `ok=true`(= HTTP 2xx **그리고** 본문 ok)를 돌려준 것이다.
6
+ // 2. 재시도는 항상 **같은 `clientSubmissionId`** 로 나간다. 서버가 이 값을 멱등 키로
7
+ // 쓰므로 몇 번을 재시도해도 서버에 제보는 1건만 남는다.
8
+ // 3. 보관 한도는 **최대 50건 / 최대 7일**. 넘치면 오래된 것부터 버리고 개발자 콘솔에
9
+ // 경고한다(사용자에게는 노출하지 않는다).
10
+ // 4. 저장소 한도에 걸리면 **스크린샷만 떼어내고 제보 본문은 보관**한다. 스크린샷은
11
+ // 다시 만들 수 있지만 사용자가 쓴 글은 다시 만들 수 없다.
12
+ // 5. 백오프는 5초에서 시작해 2배씩, **상한 5분**. 단 429(요청 한도 초과)는 이 상한의
13
+ // 예외로 **최소 10분** 뒤에 다시 시도한다.
14
+ // 6. 대기 중인 제보가 여러 건이면 **한 건씩 순서대로** 보낸다(서버 부하와 순서 보존).
15
+ //
16
+ // 재시도 트리거는 3가지다: 마운트(start) / 네트워크 복귀(`online` 이벤트 · 앱 포그라운드
17
+ // 복귀 시 호스트가 `retryNow()` 호출) / 사용자의 [다시 보내기](`retryNow()`).
18
+ //
19
+ // 인스턴스 독립: 큐는 주입받은 storage 인스턴스에만 의존한다. 저장소 구현
20
+ // (localStorage/AsyncStorage/메모리)이 바뀌어도 동작은 동일하다(인터페이스만 안다).
21
+ //
22
+ // 코어는 글로벌을 globalThis 로만 참조한다(document/window/RN 모듈 직접 접근 금지).
23
+
24
+ import type {
25
+ FeedbackAdapter,
26
+ FeedbackReport,
27
+ FeedbackStorage,
28
+ SubmitResult,
29
+ } from "./types.js";
30
+
31
+ const QUEUE_KEY = "feedback-kit:queue";
32
+
33
+ /** 대기 큐 최대 길이. 넘치면 오래된 것부터 버린다. */
34
+ export const MAX_QUEUE_ITEMS = 50;
35
+
36
+ /** 대기 큐 최대 보관 기간(ms). 7일. */
37
+ export const MAX_QUEUE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
38
+
39
+ /** 백오프 시작 간격(ms). */
40
+ export const BACKOFF_BASE_MS = 5 * 1000;
41
+
42
+ /** 백오프 상한(ms). 429 는 이 상한의 예외다. */
43
+ export const BACKOFF_MAX_MS = 5 * 60 * 1000;
44
+
45
+ /** 429 를 받았을 때의 최소 대기(ms). 상한 5분을 넘겨 잡는다. */
46
+ export const RATE_LIMIT_MIN_DELAY_MS = 10 * 60 * 1000;
47
+
48
+ /** 저장된 큐 항목. JSON 직렬화 가능한 값으로만 구성한다. */
49
+ interface QueuedReport {
50
+ /** 조립된 전체 제보. */
51
+ report: FeedbackReport;
52
+ /** 지금까지 시도한 횟수. */
53
+ attempts: number;
54
+ /** 다음 전송 시도 에포크(ms). null = 즉시. */
55
+ nextAttemptAt: number | null;
56
+ /** 큐에 들어온 시각(에포크 ms). 7일 보관 한도의 기준. */
57
+ enqueuedAt: number;
58
+ /** 마지막 실패가 429 였는지. true 면 수동 재시도로도 대기를 앞당기지 않는다. */
59
+ rateLimited?: boolean;
60
+ /** 저장소 한도 때문에 스크린샷을 떼어냈는지(진단용). */
61
+ screenshotDropped?: boolean;
62
+ }
63
+
64
+ /** UI 가 구독하는 전송 상태. */
65
+ export interface QueueStatus {
66
+ /** idle=대기 중 제보 없음, sending=전송 중, pending=대기 중(연결되면 자동 전송). */
67
+ state: "idle" | "sending" | "pending";
68
+ /** 대기 중인 제보 수. */
69
+ pending: number;
70
+ /** 마지막 실패 요약(사용자 노출용 아님, 개발자 진단용). 성공/초기엔 null. */
71
+ lastError: string | null;
72
+ }
73
+
74
+ /** `submit()` 한 건의 결과. */
75
+ export interface SubmitOutcome {
76
+ /** 서버가 수락(2xx + ok=true)했는가. */
77
+ delivered: boolean;
78
+ /** 서버가 부여한 id(표시용). 수락 전이면 null. */
79
+ id: string | null;
80
+ /** 아직 큐에 남아 있는가(=나중에 자동 재전송). */
81
+ queued: boolean;
82
+ }
83
+
84
+ export type QueueStatusListener = (status: QueueStatus) => void;
85
+
86
+ export interface FeedbackQueueOpts {
87
+ storage: FeedbackStorage;
88
+ adapter: FeedbackAdapter;
89
+ /** 주기적 flush 간격(ms). 기본 30000. 0/음수면 주기 flush 를 끈다. */
90
+ flushIntervalMs?: number;
91
+ /** 시각 주입(테스트용). */
92
+ now?: () => number;
93
+ /** 백오프 계산 주입(테스트용). */
94
+ backoff?: (
95
+ attempts: number,
96
+ retryAfterMs: number | null,
97
+ rateLimited: boolean
98
+ ) => number;
99
+ /**
100
+ * 개발자 콘솔 경고 싱크. 기본은 `console.warn`.
101
+ * 진단 버퍼가 콘솔을 패치한 환경에서는 원본 콘솔(`DiagnosticsCollector.originalConsole.warn`)을
102
+ * 넘겨 위젯 자신의 경고가 다시 수집되는 되먹임을 막는다.
103
+ */
104
+ warn?: (...a: unknown[]) => void;
105
+ }
106
+
107
+ /**
108
+ * 지수 백오프 계산.
109
+ * - 429(rateLimited)면 상한 예외: `max(10분, 서버 권장)`.
110
+ * - 서버가 `retryAfterMs` 를 줬으면 그것을 쓰되 5분으로 자른다.
111
+ * - 아니면 5초 * 2^(attempts-1), 상한 5분.
112
+ *
113
+ * attempts=10 이면 5s*2^9 = 2560s 지만 상한에 걸려 정확히 5분이다(연속 실패해도 간격이
114
+ * 5분을 넘지 않는다).
115
+ */
116
+ export function defaultBackoff(
117
+ attempts: number,
118
+ retryAfterMs: number | null,
119
+ rateLimited = false
120
+ ): number {
121
+ const serverHint =
122
+ typeof retryAfterMs === "number" && retryAfterMs >= 0 ? retryAfterMs : null;
123
+
124
+ if (rateLimited) {
125
+ return Math.max(RATE_LIMIT_MIN_DELAY_MS, serverHint ?? 0);
126
+ }
127
+ if (serverHint !== null) {
128
+ return Math.min(serverHint, BACKOFF_MAX_MS);
129
+ }
130
+ const v = BACKOFF_BASE_MS * Math.pow(2, Math.max(0, attempts - 1));
131
+ return Math.min(v, BACKOFF_MAX_MS);
132
+ }
133
+
134
+ export class FeedbackQueue {
135
+ private storage: FeedbackStorage;
136
+ private adapter: FeedbackAdapter;
137
+ private flushIntervalMs: number;
138
+ private now: () => number;
139
+ private backoff: (a: number, r: number | null, rl: boolean) => number;
140
+ private warn: (...a: unknown[]) => void;
141
+
142
+ /**
143
+ * 저장소 접근 직렬화 체인.
144
+ *
145
+ * 왜 필요한가: `load → 수정 → save` 사이에 다른 경로가 끼어들면 그 사이 쓰기가 통째로
146
+ * 사라진다(flush 가 [A]를 읽는 동안 enqueue 가 [A,B]를 저장 → flush 가 []를 저장 →
147
+ * B 유실). 단순한 `flushing` 불리언은 재진입을 "무시"할 뿐이라 이 경합을 못 막고,
148
+ * 무시된 flush 를 기다리던 `submit()` 이 자기 전송을 await 할 수도 없다.
149
+ */
150
+ private chain: Promise<unknown> = Promise.resolve();
151
+ /** 아직 시작하지 않은 flush 요청(있으면 재사용해서 타이머 연타를 합친다). */
152
+ private queuedFlush: Promise<void> | null = null;
153
+
154
+ private timer: unknown | null = null;
155
+ private onlineHandler: (() => void) | null = null;
156
+
157
+ private pending = 0;
158
+ private sending = false;
159
+ private lastError: string | null = null;
160
+ private listeners = new Set<QueueStatusListener>();
161
+
162
+ /** 제출 단위 결과 캐시. `submit()` 이 자기 건의 결말을 알아내는 데 쓴다. */
163
+ private outcomes = new Map<string, { delivered: boolean; id: string | null }>();
164
+
165
+ constructor(opts: FeedbackQueueOpts) {
166
+ this.storage = opts.storage;
167
+ this.adapter = opts.adapter;
168
+ this.flushIntervalMs = opts.flushIntervalMs ?? 30000;
169
+ this.now = opts.now ?? (() => Date.now());
170
+ this.backoff = opts.backoff ?? defaultBackoff;
171
+ this.warn = opts.warn ?? defaultWarn;
172
+ }
173
+
174
+ // ── 공개 API ──────────────────────────────────────────────
175
+
176
+ /**
177
+ * 제보를 큐에 넣고 전송을 시도한 뒤, 그 한 건의 결말을 돌려준다.
178
+ * 실패해도 예외를 던지지 않는다 — 제보는 큐에 남고 `queued: true` 로 알린다.
179
+ */
180
+ async submit(report: FeedbackReport): Promise<SubmitOutcome> {
181
+ this.outcomes.delete(report.clientSubmissionId);
182
+ await this.serialize(() => this.enqueueInner(report));
183
+ await this.flush();
184
+ const outcome = this.outcomes.get(report.clientSubmissionId);
185
+ if (outcome) {
186
+ return { delivered: outcome.delivered, id: outcome.id, queued: false };
187
+ }
188
+ return { delivered: false, id: null, queued: true };
189
+ }
190
+
191
+ /** 큐에 제보를 추가하고, 전송을 백그라운드로 시도한다. */
192
+ async enqueue(report: FeedbackReport): Promise<void> {
193
+ await this.serialize(() => this.enqueueInner(report));
194
+ void this.flush();
195
+ }
196
+
197
+ /**
198
+ * 만기된 큐 항목들을 한 건씩 순서대로 전송한다.
199
+ * 이미 진행 중이면 그 뒤에 한 번만 예약해 붙는다(타이머 연타로 flush 가 쌓이지 않는다).
200
+ */
201
+ flush(): Promise<void> {
202
+ if (this.queuedFlush) return this.queuedFlush;
203
+ const p = this.serialize(async () => {
204
+ this.queuedFlush = null;
205
+ await this.doFlush();
206
+ });
207
+ this.queuedFlush = p;
208
+ return p;
209
+ }
210
+
211
+ /**
212
+ * 지금 즉시 재시도한다. 네트워크 복귀·앱 포그라운드 복귀·[다시 보내기] 용.
213
+ * 백오프 대기를 앞당기되, **429 로 묶인 항목은 앞당기지 않는다**(서버가 명시한 한도라
214
+ * 무시하면 다시 429 만 받는다).
215
+ */
216
+ async retryNow(): Promise<void> {
217
+ await this.serialize(async () => {
218
+ const queue = await this.load();
219
+ let changed = false;
220
+ for (const item of queue) {
221
+ if (item.rateLimited === true) continue;
222
+ if (item.nextAttemptAt !== null) {
223
+ item.nextAttemptAt = null;
224
+ changed = true;
225
+ }
226
+ }
227
+ if (changed) await this.persist(queue);
228
+ });
229
+ await this.flush();
230
+ }
231
+
232
+ /** 현재 큐 길이(대기 중 제보 수). 검증/진단용. */
233
+ async size(): Promise<number> {
234
+ const queue = await this.serialize(() => this.load());
235
+ this.setPending(queue.length);
236
+ return queue.length;
237
+ }
238
+
239
+ /** 현재 전송 상태 스냅샷. */
240
+ getStatus(): QueueStatus {
241
+ return {
242
+ state: this.sending ? "sending" : this.pending > 0 ? "pending" : "idle",
243
+ pending: this.pending,
244
+ lastError: this.lastError,
245
+ };
246
+ }
247
+
248
+ /** 특정 clientSubmissionId의 마지막 확정 결말. 큐에 있거나 아직 시도 전이면 null이다. */
249
+ getOutcome(
250
+ clientSubmissionId: string
251
+ ): Pick<SubmitOutcome, "delivered" | "id"> | null {
252
+ return this.outcomes.get(clientSubmissionId) ?? null;
253
+ }
254
+
255
+ /** 전송 상태 구독. 반환값을 호출하면 해지된다. 구독 즉시 현재 상태를 1회 통지한다. */
256
+ subscribe(listener: QueueStatusListener): () => void {
257
+ this.listeners.add(listener);
258
+ try {
259
+ listener(this.getStatus());
260
+ } catch {
261
+ /* 구독자 예외가 큐를 멈추게 하지 않는다. */
262
+ }
263
+ return () => {
264
+ this.listeners.delete(listener);
265
+ };
266
+ }
267
+
268
+ /**
269
+ * 마운트 시 호출. 만기 항목 정리 + 즉시 재시도 + 주기 flush + `online` 재시도를 건다.
270
+ * 앱(RN)에는 `online` 이벤트가 없으므로, 호스트가 포그라운드 복귀 시 `retryNow()` 를
271
+ * 직접 부른다(코어는 플랫폼 API 를 모른다).
272
+ */
273
+ start(): void {
274
+ this.stop();
275
+
276
+ const g = globalThis as {
277
+ addEventListener?: (t: string, h: () => void) => void;
278
+ setInterval?: (h: () => void, ms: number) => unknown;
279
+ };
280
+
281
+ if (typeof g.addEventListener === "function") {
282
+ const handler = (): void => {
283
+ void this.retryNow();
284
+ };
285
+ g.addEventListener("online", handler);
286
+ this.onlineHandler = handler;
287
+ }
288
+
289
+ if (this.flushIntervalMs > 0 && typeof g.setInterval === "function") {
290
+ this.timer = g.setInterval(() => {
291
+ void this.flush();
292
+ }, this.flushIntervalMs);
293
+ }
294
+
295
+ void this.retryNow();
296
+ }
297
+
298
+ /** 타이머와 이벤트 구독을 해제한다. 큐 내용은 그대로 남는다. */
299
+ stop(): void {
300
+ const g = globalThis as {
301
+ removeEventListener?: (t: string, h: () => void) => void;
302
+ clearInterval?: (t: unknown) => void;
303
+ };
304
+ if (this.timer !== null) {
305
+ if (typeof g.clearInterval === "function") g.clearInterval(this.timer);
306
+ this.timer = null;
307
+ }
308
+ if (this.onlineHandler !== null) {
309
+ if (typeof g.removeEventListener === "function") {
310
+ g.removeEventListener("online", this.onlineHandler);
311
+ }
312
+ this.onlineHandler = null;
313
+ }
314
+ }
315
+
316
+ // ── 내부: 적재 / 전송 ─────────────────────────────────────
317
+
318
+ private async enqueueInner(report: FeedbackReport): Promise<void> {
319
+ const queue = this.purgeExpired(await this.load());
320
+ queue.push({
321
+ report,
322
+ attempts: 0,
323
+ nextAttemptAt: null,
324
+ enqueuedAt: this.now(),
325
+ });
326
+
327
+ // 보관 한도(50건) — 오래된 것부터 버리고 개발자 콘솔에 경고한다.
328
+ while (queue.length > MAX_QUEUE_ITEMS) {
329
+ const dropped = queue.shift();
330
+ this.warn(
331
+ `feedback-kit: 대기 큐가 ${MAX_QUEUE_ITEMS}건을 넘어 가장 오래된 제보를 버렸다`,
332
+ dropped?.report.clientSubmissionId
333
+ );
334
+ }
335
+
336
+ await this.persist(queue);
337
+ }
338
+
339
+ private async doFlush(): Promise<void> {
340
+ let queue = await this.load();
341
+
342
+ const kept = this.purgeExpired(queue);
343
+ if (kept.length !== queue.length) {
344
+ queue = kept;
345
+ await this.persist(queue);
346
+ }
347
+ this.setPending(queue.length);
348
+ if (queue.length === 0) {
349
+ this.emit();
350
+ return;
351
+ }
352
+
353
+ const now = this.now();
354
+ const due = queue.filter(
355
+ (item) => item.nextAttemptAt === null || now >= item.nextAttemptAt
356
+ );
357
+ if (due.length === 0) {
358
+ this.emit();
359
+ return;
360
+ }
361
+
362
+ this.sending = true;
363
+ this.emit();
364
+ try {
365
+ // 한 건씩 순서대로. 앞 건이 끝나야 다음 건을 보낸다.
366
+ for (const item of due) {
367
+ if (queue.indexOf(item) === -1) continue; // 저장 한도로 밀려나 사라진 항목.
368
+
369
+ const result = await this.submitOne(item.report);
370
+ const at = this.now();
371
+ const index = queue.indexOf(item);
372
+
373
+ if (result.ok) {
374
+ if (index !== -1) queue.splice(index, 1);
375
+ this.recordOutcome(item.report.clientSubmissionId, true, result.id);
376
+ this.lastError = null;
377
+ } else if (!result.retryable) {
378
+ // 영구 실패(예: 스키마 거부) — 재시도해도 결과가 같으므로 데드레터.
379
+ if (index !== -1) queue.splice(index, 1);
380
+ this.recordOutcome(item.report.clientSubmissionId, false, null);
381
+ this.lastError = "non-retryable";
382
+ this.warn(
383
+ "feedback-kit: 재시도 불가 실패로 제보를 버렸다",
384
+ item.report.clientSubmissionId
385
+ );
386
+ } else {
387
+ // 일시적 실패 — 같은 clientSubmissionId 로 나중에 다시 보낸다.
388
+ item.attempts += 1;
389
+ item.rateLimited = result.rateLimited === true;
390
+ item.nextAttemptAt =
391
+ at + this.backoff(item.attempts, result.retryAfterMs, item.rateLimited);
392
+ this.lastError = item.rateLimited ? "rate-limited" : "retryable";
393
+ }
394
+
395
+ await this.persist(queue);
396
+ this.setPending(queue.length);
397
+ this.emit();
398
+ }
399
+ } finally {
400
+ this.sending = false;
401
+ this.emit();
402
+ }
403
+ }
404
+
405
+ /** 어댑터 호출. 어댑터가 예외를 던져도 결과값으로 흡수한다(전송 보장 우선). */
406
+ private async submitOne(report: FeedbackReport): Promise<SubmitResult> {
407
+ try {
408
+ return await this.adapter.submit(report);
409
+ } catch (err) {
410
+ this.warn("feedback-kit: 어댑터가 예외를 던졌다(일시 실패로 처리)", err);
411
+ return { ok: false, id: null, retryable: true, retryAfterMs: null };
412
+ }
413
+ }
414
+
415
+ // ── 내부: 저장소 ──────────────────────────────────────────
416
+
417
+ private async load(): Promise<QueuedReport[]> {
418
+ let raw: string | null = null;
419
+ try {
420
+ raw = await this.storage.get(QUEUE_KEY);
421
+ } catch (err) {
422
+ this.warn("feedback-kit: 큐를 읽지 못했다", err);
423
+ return [];
424
+ }
425
+ if (!raw) return [];
426
+ try {
427
+ const parsed: unknown = JSON.parse(raw);
428
+ if (!Array.isArray(parsed)) return [];
429
+ return parsed.filter(isQueuedReport).map(normalizeEntry(this.now()));
430
+ } catch {
431
+ // 손상된 큐는 빈 것으로 취급(복구 불가).
432
+ this.warn("feedback-kit: 큐가 손상돼 초기화했다");
433
+ return [];
434
+ }
435
+ }
436
+
437
+ /**
438
+ * 큐를 저장한다. 저장소 한도(쿼터)에 걸리면 **스크린샷부터** 떼어내고, 그래도 안 되면
439
+ * 가장 오래된 제보를 버린다. 사용자가 쓴 글이 스크린샷보다 우선이다.
440
+ * 인자로 받은 배열을 제자리에서 수정한다(호출부가 들고 있는 항목 참조는 유지된다).
441
+ */
442
+ private async persist(queue: QueuedReport[]): Promise<void> {
443
+ for (;;) {
444
+ try {
445
+ await this.storage.set(QUEUE_KEY, JSON.stringify(queue));
446
+ this.setPending(queue.length);
447
+ return;
448
+ } catch (err) {
449
+ if (queue.length === 0) {
450
+ this.warn("feedback-kit: 큐를 저장하지 못했다", err);
451
+ return;
452
+ }
453
+ const shotIndex = queue.findIndex(
454
+ (item) => item.report.screenshot !== null
455
+ );
456
+ if (shotIndex !== -1) {
457
+ const entry = queue[shotIndex] as QueuedReport;
458
+ // 원본 report 객체를 건드리지 않고 새 객체로 교체한다(호출자 소유물 보호).
459
+ entry.report = { ...entry.report, screenshot: null };
460
+ entry.screenshotDropped = true;
461
+ this.warn(
462
+ "feedback-kit: 저장 한도를 넘어 스크린샷을 떼어내고 제보만 보관한다",
463
+ entry.report.clientSubmissionId
464
+ );
465
+ continue;
466
+ }
467
+ const evicted = queue.shift();
468
+ this.warn(
469
+ "feedback-kit: 저장 한도를 넘어 가장 오래된 제보를 버렸다",
470
+ evicted?.report.clientSubmissionId
471
+ );
472
+ }
473
+ }
474
+ }
475
+
476
+ /** 7일이 지난 항목을 걸러낸다. 만기 항목은 전송을 시도하지 않는다. */
477
+ private purgeExpired(queue: QueuedReport[]): QueuedReport[] {
478
+ const now = this.now();
479
+ const kept = queue.filter((item) => now - item.enqueuedAt <= MAX_QUEUE_AGE_MS);
480
+ if (kept.length !== queue.length) {
481
+ this.warn(
482
+ `feedback-kit: 보관 기간(7일)이 지난 제보 ${queue.length - kept.length}건을 정리했다`
483
+ );
484
+ }
485
+ return kept;
486
+ }
487
+
488
+ // ── 내부: 상태 통지 ───────────────────────────────────────
489
+
490
+ private recordOutcome(id: string, delivered: boolean, serverId: string | null): void {
491
+ // 무한 증가를 막는다. 오래된 것부터 버린다(결말은 UI 가 즉시 읽고 버리는 값).
492
+ if (this.outcomes.size >= 100) {
493
+ const oldest = this.outcomes.keys().next();
494
+ if (!oldest.done) this.outcomes.delete(oldest.value);
495
+ }
496
+ this.outcomes.set(id, { delivered, id: serverId });
497
+ }
498
+
499
+ private setPending(n: number): void {
500
+ if (this.pending === n) return;
501
+ this.pending = n;
502
+ }
503
+
504
+ private emit(): void {
505
+ if (this.listeners.size === 0) return;
506
+ const status = this.getStatus();
507
+ for (const listener of this.listeners) {
508
+ try {
509
+ listener(status);
510
+ } catch {
511
+ /* 구독자 예외가 큐를 멈추게 하지 않는다. */
512
+ }
513
+ }
514
+ }
515
+
516
+ private serialize<T>(fn: () => Promise<T>): Promise<T> {
517
+ const next = this.chain.then(fn, fn);
518
+ this.chain = next.catch(() => undefined);
519
+ return next;
520
+ }
521
+ }
522
+
523
+ // ── 모듈 내부 헬퍼 ──────────────────────────────────────────
524
+
525
+ function defaultWarn(...a: unknown[]): void {
526
+ const g = globalThis as { console?: { warn?: (...a: unknown[]) => void } };
527
+ g.console?.warn?.(...a);
528
+ }
529
+
530
+ function isQueuedReport(v: unknown): v is QueuedReport {
531
+ if (typeof v !== "object" || v === null) return false;
532
+ const report = (v as { report?: unknown }).report;
533
+ if (typeof report !== "object" || report === null) return false;
534
+ return typeof (report as { clientSubmissionId?: unknown }).clientSubmissionId === "string";
535
+ }
536
+
537
+ /** 이전 버전이 저장한 항목에도 견디도록 필드를 보정한다. */
538
+ function normalizeEntry(now: number): (v: QueuedReport) => QueuedReport {
539
+ return (v) => ({
540
+ ...v,
541
+ attempts: typeof v.attempts === "number" && v.attempts >= 0 ? v.attempts : 0,
542
+ nextAttemptAt: typeof v.nextAttemptAt === "number" ? v.nextAttemptAt : null,
543
+ // enqueuedAt 이 없던 시절의 항목은 "방금 들어온 것"으로 봐서 즉시 버리지 않는다.
544
+ enqueuedAt: typeof v.enqueuedAt === "number" ? v.enqueuedAt : now,
545
+ });
546
+ }
package/src/report.ts ADDED
@@ -0,0 +1,58 @@
1
+ // 제보 조립 — 호스트가 준 부분 데이터로 FeedbackReport(전체 계약)를 만든다.
2
+ //
3
+ // 핵심 규칙:
4
+ // - 호스트는 kind/comment/priority/screenshot/pin/element 만 넘긴다.
5
+ // clientSubmissionId / createdAt / context 는 코어가 여기서 채운다.
6
+ // - context(사용자·화면·기기·진단) 조립은 context.ts 가 담당한다. 여기서는 쓰기만 한다.
7
+ // - getUser는 prop(함수). 제출 시점에 호출한다. 캐싱하지 않는다 → 로그인/로그아웃으로
8
+ // 반환값이 바뀌어도 제출 순간의 최신 신원이 들어간다.
9
+
10
+ import type { BuildContextOpts } from "./context.js";
11
+ import { buildContext } from "./context.js";
12
+ import type {
13
+ ElementInfo,
14
+ FeedbackKind,
15
+ FeedbackPin,
16
+ FeedbackPriority,
17
+ FeedbackReport,
18
+ FeedbackScreenshot,
19
+ } from "./types.js";
20
+ import { uuidv4 } from "./uuid.js";
21
+
22
+ /** 호스트가 submit() 호출 시 넘기는 부분 데이터. 계약의 나머지는 코어가 채운다. */
23
+ export interface ReportParts {
24
+ kind: FeedbackKind;
25
+ comment: string;
26
+ priority: FeedbackPriority;
27
+ screenshot: FeedbackScreenshot | null;
28
+ pin: FeedbackPin | null;
29
+ element: ElementInfo | null;
30
+ }
31
+
32
+ /** 제보 조립 옵션. 컨텍스트 조립 옵션과 동일하다(제보 쪽에 추가 입력이 없다). */
33
+ export type BuildReportOpts = BuildContextOpts;
34
+
35
+ /**
36
+ * 부분 데이터 + 옵션으로 FeedbackReport 하나를 만든다.
37
+ * clientSubmissionId(UUID)와 createdAt(ISO)은 매 호출마다 새로 생성.
38
+ */
39
+ export async function buildReport(
40
+ parts: ReportParts,
41
+ opts: BuildReportOpts
42
+ ): Promise<FeedbackReport> {
43
+ const now = opts.now ?? ((): number => Date.now());
44
+ const iso = opts.iso ?? ((n: number): string => new Date(n).toISOString());
45
+ const context = await buildContext(opts);
46
+
47
+ return {
48
+ clientSubmissionId: uuidv4(),
49
+ kind: parts.kind,
50
+ comment: parts.comment,
51
+ priority: parts.priority,
52
+ screenshot: parts.screenshot,
53
+ pin: parts.pin,
54
+ element: parts.element,
55
+ context,
56
+ createdAt: iso(now()),
57
+ };
58
+ }
@@ -0,0 +1,62 @@
1
+ // 링버퍼 자체의 밀어내기 규칙(DP-253 TC4 의 단위 근거).
2
+ //
3
+ // 여기서는 "상한을 넘으면 오래된 것부터 밀려나고, 남는 건 최근 것"만 본다.
4
+ // 실제 수집기(네트워크 30 / 로그 50)에 대한 검증은 diagnostics.test.ts 에 있다.
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { RingBuffer } from "./ring-buffer.js";
9
+
10
+ describe("RingBuffer", () => {
11
+ it("상한 이하에서는 넣은 순서 그대로 보관한다", () => {
12
+ const buf = new RingBuffer<number>(3);
13
+ buf.push(1);
14
+ buf.push(2);
15
+
16
+ expect(buf.size).toBe(2);
17
+ expect(buf.toArray()).toEqual([1, 2]);
18
+ });
19
+
20
+ it("상한을 넘으면 오래된 것부터 밀려나고 최근 것만 남는다", () => {
21
+ const buf = new RingBuffer<number>(3);
22
+ for (let i = 1; i <= 10; i += 1) buf.push(i);
23
+
24
+ expect(buf.size).toBe(3);
25
+ // 1~7 은 밀려나고 마지막 3건만 남는다.
26
+ expect(buf.toArray()).toEqual([8, 9, 10]);
27
+ });
28
+
29
+ it("toArray 는 사본이라 이후 push 가 이미 낸 스냅샷을 바꾸지 않는다", () => {
30
+ const buf = new RingBuffer<string>(2);
31
+ buf.push("a");
32
+ const snapshot = buf.toArray();
33
+
34
+ buf.push("b");
35
+ buf.push("c");
36
+
37
+ expect(snapshot).toEqual(["a"]);
38
+ expect(buf.toArray()).toEqual(["b", "c"]);
39
+ });
40
+
41
+ it("clear 는 내용을 비우되 용량은 유지한다", () => {
42
+ const buf = new RingBuffer<number>(2);
43
+ buf.push(1);
44
+ buf.clear();
45
+
46
+ expect(buf.size).toBe(0);
47
+ expect(buf.toArray()).toEqual([]);
48
+
49
+ buf.push(9);
50
+ expect(buf.toArray()).toEqual([9]);
51
+ expect(buf.capacity).toBe(2);
52
+ });
53
+
54
+ it("용량이 0 이하/비정상이면 아무것도 담지 않는다(메모리 사고 방지)", () => {
55
+ for (const bad of [0, -5, Number.NaN]) {
56
+ const buf = new RingBuffer<number>(bad);
57
+ buf.push(1);
58
+ expect(buf.capacity).toBe(0);
59
+ expect(buf.toArray()).toEqual([]);
60
+ }
61
+ });
62
+ });