@chipmobilesdk/rn-notification 0.4.0 → 0.5.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/CHANGELOG.md +257 -166
- package/README.md +420 -410
- package/package.json +61 -75
- package/skills/sdk-rn-notification/SKILL.md +172 -173
- package/src/channel/migrate.ts +285 -289
- package/src/channel/tone-map.ts +130 -130
- package/src/channel/validate-tone.ts +61 -61
- package/src/contract/channel.ts +192 -188
- package/src/contract/diagnostics.ts +102 -102
- package/src/contract/errors.ts +142 -134
- package/src/contract/index.ts +10 -10
- package/src/contract/interaction.ts +62 -62
- package/src/contract/notification.ts +118 -118
- package/src/contract/permission.ts +55 -60
- package/src/contract/reconcile.ts +88 -88
- package/src/contract/recurrence.ts +129 -129
- package/src/diagnostics/burst.ts +101 -101
- package/src/diagnostics/events.ts +81 -72
- package/src/diagnostics/redact.ts +55 -72
- package/src/diagnostics/snapshot.ts +100 -64
- package/src/engine/adapter.ts +172 -172
- package/src/engine/codec.ts +89 -89
- package/src/engine/notify-kit-engine.ts +715 -707
- package/src/index.ts +208 -208
- package/src/initialize.ts +247 -236
- package/src/interaction/index.ts +146 -146
- package/src/maintenance/cycle.ts +150 -154
- package/src/maintenance/permission-refresh.ts +39 -41
- package/src/maintenance/reanchor.ts +101 -101
- package/src/maintenance/toggle.ts +66 -64
- package/src/maintenance/verify.ts +83 -83
- package/src/maintenance/window-refresh.ts +64 -64
- package/src/permission/index.ts +183 -183
- package/src/permission/state.ts +77 -77
- package/src/push/envelope.ts +165 -165
- package/src/push/fcm-provider.ts +150 -146
- package/src/push/gate.ts +190 -190
- package/src/push/index.ts +33 -29
- package/src/push/instructions.ts +114 -109
- package/src/push/provider-adapter.ts +54 -54
- package/src/push/signout.ts +65 -65
- package/src/reconcile/chunk.ts +53 -53
- package/src/reconcile/diff.ts +142 -145
- package/src/reconcile/duplicates.ts +73 -73
- package/src/reconcile/index.ts +180 -180
- package/src/reconcile/truncate.ts +56 -56
- package/src/recurrence/clock-guard.ts +73 -71
- package/src/recurrence/expand.ts +326 -319
- package/src/recurrence/index.ts +270 -259
- package/src/repeat/expiry.ts +53 -55
- package/src/repeat/index.ts +54 -54
- package/src/repeat/marker.ts +72 -72
- package/src/repeat/place.ts +73 -73
- package/src/schedule/anchor.ts +294 -267
- package/src/schedule/index.ts +174 -181
- package/src/schedule/partition.ts +88 -85
- package/src/schedule/resolve-moment.ts +80 -80
- package/src/schedule/validate-content.ts +111 -111
- package/src/schedule/validate-identifier.ts +126 -126
- package/src/schedule/writer.ts +55 -55
- package/src/state/caps.ts +71 -71
- package/src/state/dedup-ledger.ts +35 -35
- package/src/state/interaction-buffer.ts +101 -73
- package/src/state/mmkv-store.ts +92 -75
- package/src/state/permission-history.ts +41 -41
- package/src/state/record.ts +270 -263
- package/src/state/reset.ts +41 -41
- package/src/state/series-ledger.ts +91 -91
- package/src/state/store.ts +43 -40
- package/src/testing/conformance/index.ts +258 -248
- package/src/testing/fake-engine.ts +462 -468
- package/src/testing/fake-provider.ts +126 -129
- package/src/testing/faults.ts +72 -72
- package/src/testing/index.ts +23 -28
- package/src/testing/measure.ts +337 -337
|
@@ -1,129 +1,129 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Recurrence declaration types.
|
|
3
|
-
*
|
|
4
|
-
* A caller declares a rule once; the package expands it inside a finite
|
|
5
|
-
* lookahead window. No caller ever writes an expansion loop, and no unbounded
|
|
6
|
-
* series is ever handed to the platform.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { AnchoringMode, NotificationContent, Action, RoutingPayload } from './notification';
|
|
10
|
-
|
|
11
|
-
/** Days of the week, `0` = Sunday, matching `Date.prototype.getDay`. */
|
|
12
|
-
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
13
|
-
|
|
14
|
-
/** Every day at the declared clock time. */
|
|
15
|
-
export interface DailyPattern {
|
|
16
|
-
readonly kind: 'daily';
|
|
17
|
-
readonly hour: number;
|
|
18
|
-
readonly minute: number;
|
|
19
|
-
/** Fire every `interval` days. Defaults to 1. */
|
|
20
|
-
readonly interval?: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Selected weekdays at the declared clock time. */
|
|
24
|
-
export interface WeeklyPattern {
|
|
25
|
-
readonly kind: 'weekly';
|
|
26
|
-
readonly weekdays: readonly Weekday[];
|
|
27
|
-
readonly hour: number;
|
|
28
|
-
readonly minute: number;
|
|
29
|
-
/** Fire every `interval` weeks. Defaults to 1. */
|
|
30
|
-
readonly interval?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* A fixed day of the month at the declared clock time.
|
|
35
|
-
*
|
|
36
|
-
* Months shorter than `dayOfMonth` are skipped rather than clamped: a caller
|
|
37
|
-
* asking for the 31st means the 31st, and inventing the 28th would be the
|
|
38
|
-
* package deciding something it was not asked to decide.
|
|
39
|
-
*/
|
|
40
|
-
export interface MonthlyByDayPattern {
|
|
41
|
-
readonly kind: 'monthlyByDay';
|
|
42
|
-
readonly dayOfMonth: number;
|
|
43
|
-
readonly hour: number;
|
|
44
|
-
readonly minute: number;
|
|
45
|
-
/** Fire every `interval` months. Defaults to 1. */
|
|
46
|
-
readonly interval?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type RecurrencePattern = DailyPattern | WeeklyPattern | MonthlyByDayPattern;
|
|
50
|
-
|
|
51
|
-
/** When a series stops producing occurrences. */
|
|
52
|
-
export type EndCondition =
|
|
53
|
-
| { readonly kind: 'unbounded' }
|
|
54
|
-
| { readonly kind: 'untilDate'; readonly until: number }
|
|
55
|
-
| { readonly kind: 'count'; readonly count: number };
|
|
56
|
-
|
|
57
|
-
/** Content shared by every occurrence of a series. Identity and moment are derived. */
|
|
58
|
-
export interface OccurrenceTemplate {
|
|
59
|
-
readonly content: NotificationContent;
|
|
60
|
-
readonly tone: string;
|
|
61
|
-
readonly routing?: RoutingPayload;
|
|
62
|
-
readonly actions?: readonly Action[];
|
|
63
|
-
readonly ownerTag?: string;
|
|
64
|
-
readonly groupTag?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface RecurrenceRule {
|
|
68
|
-
/** Stable series identifier, subject to the same charset rules as a notification id. */
|
|
69
|
-
readonly id: string;
|
|
70
|
-
readonly pattern: RecurrencePattern;
|
|
71
|
-
/**
|
|
72
|
-
* Declared once for the whole series and inherited by every occurrence.
|
|
73
|
-
* A series may not mix modes. Defaults to `wallClock`.
|
|
74
|
-
*/
|
|
75
|
-
readonly anchoring?: AnchoringMode;
|
|
76
|
-
/** The instant from which occurrences are generated. Defaults to now. */
|
|
77
|
-
readonly startAt?: number;
|
|
78
|
-
readonly end: EndCondition;
|
|
79
|
-
readonly template: OccurrenceTemplate;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* How far ahead occurrences are registered with the platform.
|
|
84
|
-
*
|
|
85
|
-
* Bounded by both a duration and a count because either alone fails: a duration
|
|
86
|
-
* alone lets a frequent series exhaust the platform ceiling, and a count alone
|
|
87
|
-
* leaves an annual series scheduled a decade out.
|
|
88
|
-
*/
|
|
89
|
-
export interface LookaheadConfig {
|
|
90
|
-
readonly days: number;
|
|
91
|
-
readonly occurrencesPerSeries: number;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export const DEFAULT_LOOKAHEAD: LookaheadConfig = {
|
|
95
|
-
days: 60,
|
|
96
|
-
occurrencesPerSeries: 32,
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
/** Bounds on consumer adjustment, because the platform ceiling is not negotiable. */
|
|
100
|
-
export const LOOKAHEAD_BOUNDS = {
|
|
101
|
-
minDays: 1,
|
|
102
|
-
maxDays: 365,
|
|
103
|
-
minOccurrences: 1,
|
|
104
|
-
maxOccurrences: 64,
|
|
105
|
-
} as const;
|
|
106
|
-
|
|
107
|
-
export interface SeriesResult {
|
|
108
|
-
readonly seriesId: string;
|
|
109
|
-
/** Occurrences registered with the platform in this pass. */
|
|
110
|
-
readonly scheduled: number;
|
|
111
|
-
/** The horizon expansion reached, in epoch milliseconds. */
|
|
112
|
-
readonly expandedThrough: number;
|
|
113
|
-
/** True when the series has produced its final occurrence. */
|
|
114
|
-
readonly exhausted: boolean;
|
|
115
|
-
/**
|
|
116
|
-
* Upcoming occurrences of this series removed because the expansion no longer
|
|
117
|
-
* produces them — identifiers from before 0.4.0, or slots the rule dropped.
|
|
118
|
-
* Added in 0.4.0.
|
|
119
|
-
*/
|
|
120
|
-
readonly cancelled?: readonly string[];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface SeriesSummary {
|
|
124
|
-
readonly seriesId: string;
|
|
125
|
-
readonly anchoring: AnchoringMode;
|
|
126
|
-
readonly expandedThrough: number;
|
|
127
|
-
readonly pendingOccurrences: number;
|
|
128
|
-
readonly exhausted: boolean;
|
|
129
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence declaration types.
|
|
3
|
+
*
|
|
4
|
+
* A caller declares a rule once; the package expands it inside a finite
|
|
5
|
+
* lookahead window. No caller ever writes an expansion loop, and no unbounded
|
|
6
|
+
* series is ever handed to the platform.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AnchoringMode, NotificationContent, Action, RoutingPayload } from './notification';
|
|
10
|
+
|
|
11
|
+
/** Days of the week, `0` = Sunday, matching `Date.prototype.getDay`. */
|
|
12
|
+
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
|
13
|
+
|
|
14
|
+
/** Every day at the declared clock time. */
|
|
15
|
+
export interface DailyPattern {
|
|
16
|
+
readonly kind: 'daily';
|
|
17
|
+
readonly hour: number;
|
|
18
|
+
readonly minute: number;
|
|
19
|
+
/** Fire every `interval` days. Defaults to 1. */
|
|
20
|
+
readonly interval?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Selected weekdays at the declared clock time. */
|
|
24
|
+
export interface WeeklyPattern {
|
|
25
|
+
readonly kind: 'weekly';
|
|
26
|
+
readonly weekdays: readonly Weekday[];
|
|
27
|
+
readonly hour: number;
|
|
28
|
+
readonly minute: number;
|
|
29
|
+
/** Fire every `interval` weeks. Defaults to 1. */
|
|
30
|
+
readonly interval?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A fixed day of the month at the declared clock time.
|
|
35
|
+
*
|
|
36
|
+
* Months shorter than `dayOfMonth` are skipped rather than clamped: a caller
|
|
37
|
+
* asking for the 31st means the 31st, and inventing the 28th would be the
|
|
38
|
+
* package deciding something it was not asked to decide.
|
|
39
|
+
*/
|
|
40
|
+
export interface MonthlyByDayPattern {
|
|
41
|
+
readonly kind: 'monthlyByDay';
|
|
42
|
+
readonly dayOfMonth: number;
|
|
43
|
+
readonly hour: number;
|
|
44
|
+
readonly minute: number;
|
|
45
|
+
/** Fire every `interval` months. Defaults to 1. */
|
|
46
|
+
readonly interval?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RecurrencePattern = DailyPattern | WeeklyPattern | MonthlyByDayPattern;
|
|
50
|
+
|
|
51
|
+
/** When a series stops producing occurrences. */
|
|
52
|
+
export type EndCondition =
|
|
53
|
+
| { readonly kind: 'unbounded' }
|
|
54
|
+
| { readonly kind: 'untilDate'; readonly until: number }
|
|
55
|
+
| { readonly kind: 'count'; readonly count: number };
|
|
56
|
+
|
|
57
|
+
/** Content shared by every occurrence of a series. Identity and moment are derived. */
|
|
58
|
+
export interface OccurrenceTemplate {
|
|
59
|
+
readonly content: NotificationContent;
|
|
60
|
+
readonly tone: string;
|
|
61
|
+
readonly routing?: RoutingPayload;
|
|
62
|
+
readonly actions?: readonly Action[];
|
|
63
|
+
readonly ownerTag?: string;
|
|
64
|
+
readonly groupTag?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RecurrenceRule {
|
|
68
|
+
/** Stable series identifier, subject to the same charset rules as a notification id. */
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly pattern: RecurrencePattern;
|
|
71
|
+
/**
|
|
72
|
+
* Declared once for the whole series and inherited by every occurrence.
|
|
73
|
+
* A series may not mix modes. Defaults to `wallClock`.
|
|
74
|
+
*/
|
|
75
|
+
readonly anchoring?: AnchoringMode;
|
|
76
|
+
/** The instant from which occurrences are generated. Defaults to now. */
|
|
77
|
+
readonly startAt?: number;
|
|
78
|
+
readonly end: EndCondition;
|
|
79
|
+
readonly template: OccurrenceTemplate;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How far ahead occurrences are registered with the platform.
|
|
84
|
+
*
|
|
85
|
+
* Bounded by both a duration and a count because either alone fails: a duration
|
|
86
|
+
* alone lets a frequent series exhaust the platform ceiling, and a count alone
|
|
87
|
+
* leaves an annual series scheduled a decade out.
|
|
88
|
+
*/
|
|
89
|
+
export interface LookaheadConfig {
|
|
90
|
+
readonly days: number;
|
|
91
|
+
readonly occurrencesPerSeries: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_LOOKAHEAD: LookaheadConfig = {
|
|
95
|
+
days: 60,
|
|
96
|
+
occurrencesPerSeries: 32,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Bounds on consumer adjustment, because the platform ceiling is not negotiable. */
|
|
100
|
+
export const LOOKAHEAD_BOUNDS = {
|
|
101
|
+
minDays: 1,
|
|
102
|
+
maxDays: 365,
|
|
103
|
+
minOccurrences: 1,
|
|
104
|
+
maxOccurrences: 64,
|
|
105
|
+
} as const;
|
|
106
|
+
|
|
107
|
+
export interface SeriesResult {
|
|
108
|
+
readonly seriesId: string;
|
|
109
|
+
/** Occurrences registered with the platform in this pass. */
|
|
110
|
+
readonly scheduled: number;
|
|
111
|
+
/** The horizon expansion reached, in epoch milliseconds. */
|
|
112
|
+
readonly expandedThrough: number;
|
|
113
|
+
/** True when the series has produced its final occurrence. */
|
|
114
|
+
readonly exhausted: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Upcoming occurrences of this series removed because the expansion no longer
|
|
117
|
+
* produces them — identifiers from before 0.4.0, or slots the rule dropped.
|
|
118
|
+
* Added in 0.4.0.
|
|
119
|
+
*/
|
|
120
|
+
readonly cancelled?: readonly string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface SeriesSummary {
|
|
124
|
+
readonly seriesId: string;
|
|
125
|
+
readonly anchoring: AnchoringMode;
|
|
126
|
+
readonly expandedThrough: number;
|
|
127
|
+
readonly pendingOccurrences: number;
|
|
128
|
+
readonly exhausted: boolean;
|
|
129
|
+
}
|
package/src/diagnostics/burst.ts
CHANGED
|
@@ -1,101 +1,101 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Burst detection.
|
|
3
|
-
*
|
|
4
|
-
* Both platforms apply undocumented delivery rate limits and will silently drop
|
|
5
|
-
* or delay past them — which is exactly the invisible failure this package
|
|
6
|
-
* exists to eliminate. So volume is watched and reported.
|
|
7
|
-
*
|
|
8
|
-
* It is **not** acted upon. Nothing is throttled, delayed, coalesced, or
|
|
9
|
-
* dropped: deciding which of a user's notifications to suppress is a product
|
|
10
|
-
* decision the package has no basis to make. Noticing that something abnormal
|
|
11
|
-
* is happening is one it can.
|
|
12
|
-
*
|
|
13
|
-
* Counters are in memory only. A burst is a live condition, and persisting it
|
|
14
|
-
* would expand the state contract for no diagnostic gain.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { BurstThreshold } from '../contract/diagnostics';
|
|
18
|
-
import { DEFAULT_BURST_THRESHOLD } from '../contract/diagnostics';
|
|
19
|
-
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
20
|
-
|
|
21
|
-
const MINUTE_MS = 60_000;
|
|
22
|
-
const HOUR_MS = 60 * MINUTE_MS;
|
|
23
|
-
|
|
24
|
-
export interface BurstDetectorDependencies {
|
|
25
|
-
readonly threshold?: BurstThreshold;
|
|
26
|
-
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface BurstStatus {
|
|
30
|
-
readonly ownerTag: string;
|
|
31
|
-
readonly lastMinute: number;
|
|
32
|
-
readonly lastHour: number;
|
|
33
|
-
readonly exceeded: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function createBurstDetector(deps: BurstDetectorDependencies = {}) {
|
|
37
|
-
const threshold = deps.threshold ?? DEFAULT_BURST_THRESHOLD;
|
|
38
|
-
const emit = deps.emit ?? (() => {});
|
|
39
|
-
/** Per owner tag, because one busy account should not implicate another. */
|
|
40
|
-
const timestamps = new Map<string, number[]>();
|
|
41
|
-
/** Prevents one sustained burst producing a diagnostic per notification. */
|
|
42
|
-
const reportedAt = new Map<string, number>();
|
|
43
|
-
|
|
44
|
-
const prune = (tag: string, now: number): number[] => {
|
|
45
|
-
const kept = (timestamps.get(tag) ?? []).filter(at => now - at <= HOUR_MS);
|
|
46
|
-
timestamps.set(tag, kept);
|
|
47
|
-
return kept;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
/**
|
|
52
|
-
* Records one presented notification and reports if the threshold is crossed.
|
|
53
|
-
*
|
|
54
|
-
* Always returns after recording — the caller delivers regardless of what
|
|
55
|
-
* this says.
|
|
56
|
-
*/
|
|
57
|
-
record(now: number, ownerTag = '*'): BurstStatus {
|
|
58
|
-
const kept = prune(ownerTag, now);
|
|
59
|
-
kept.push(now);
|
|
60
|
-
|
|
61
|
-
const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
|
|
62
|
-
const lastHour = kept.length;
|
|
63
|
-
const exceeded = lastMinute > threshold.perMinute || lastHour > threshold.perHour;
|
|
64
|
-
|
|
65
|
-
if (exceeded) {
|
|
66
|
-
const last = reportedAt.get(ownerTag) ?? 0;
|
|
67
|
-
// One report per minute per tag; a sustained burst is one condition,
|
|
68
|
-
// not a hundred.
|
|
69
|
-
if (now - last >= MINUTE_MS) {
|
|
70
|
-
reportedAt.set(ownerTag, now);
|
|
71
|
-
emit(
|
|
72
|
-
DIAGNOSTIC_CODES.BURST_THRESHOLD_EXCEEDED,
|
|
73
|
-
`Abnormal notification volume: ${lastMinute} in the last minute and ${lastHour} in the last hour (thresholds ${threshold.perMinute}/minute, ${threshold.perHour}/hour). Every notification was still delivered — the package throttles nothing.`,
|
|
74
|
-
{ ownerTag, lastMinute, lastHour, threshold },
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return { ownerTag, lastMinute, lastHour, exceeded };
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
status(now: number, ownerTag = '*'): BurstStatus {
|
|
83
|
-
const kept = prune(ownerTag, now);
|
|
84
|
-
const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
ownerTag,
|
|
88
|
-
lastMinute,
|
|
89
|
-
lastHour: kept.length,
|
|
90
|
-
exceeded: lastMinute > threshold.perMinute || kept.length > threshold.perHour,
|
|
91
|
-
};
|
|
92
|
-
},
|
|
93
|
-
|
|
94
|
-
reset(): void {
|
|
95
|
-
timestamps.clear();
|
|
96
|
-
reportedAt.clear();
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export type BurstDetector = ReturnType<typeof createBurstDetector>;
|
|
1
|
+
/**
|
|
2
|
+
* Burst detection.
|
|
3
|
+
*
|
|
4
|
+
* Both platforms apply undocumented delivery rate limits and will silently drop
|
|
5
|
+
* or delay past them — which is exactly the invisible failure this package
|
|
6
|
+
* exists to eliminate. So volume is watched and reported.
|
|
7
|
+
*
|
|
8
|
+
* It is **not** acted upon. Nothing is throttled, delayed, coalesced, or
|
|
9
|
+
* dropped: deciding which of a user's notifications to suppress is a product
|
|
10
|
+
* decision the package has no basis to make. Noticing that something abnormal
|
|
11
|
+
* is happening is one it can.
|
|
12
|
+
*
|
|
13
|
+
* Counters are in memory only. A burst is a live condition, and persisting it
|
|
14
|
+
* would expand the state contract for no diagnostic gain.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { BurstThreshold } from '../contract/diagnostics';
|
|
18
|
+
import { DEFAULT_BURST_THRESHOLD } from '../contract/diagnostics';
|
|
19
|
+
import { DIAGNOSTIC_CODES } from '../contract/errors';
|
|
20
|
+
|
|
21
|
+
const MINUTE_MS = 60_000;
|
|
22
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
23
|
+
|
|
24
|
+
export interface BurstDetectorDependencies {
|
|
25
|
+
readonly threshold?: BurstThreshold;
|
|
26
|
+
readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BurstStatus {
|
|
30
|
+
readonly ownerTag: string;
|
|
31
|
+
readonly lastMinute: number;
|
|
32
|
+
readonly lastHour: number;
|
|
33
|
+
readonly exceeded: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createBurstDetector(deps: BurstDetectorDependencies = {}) {
|
|
37
|
+
const threshold = deps.threshold ?? DEFAULT_BURST_THRESHOLD;
|
|
38
|
+
const emit = deps.emit ?? (() => {});
|
|
39
|
+
/** Per owner tag, because one busy account should not implicate another. */
|
|
40
|
+
const timestamps = new Map<string, number[]>();
|
|
41
|
+
/** Prevents one sustained burst producing a diagnostic per notification. */
|
|
42
|
+
const reportedAt = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
const prune = (tag: string, now: number): number[] => {
|
|
45
|
+
const kept = (timestamps.get(tag) ?? []).filter(at => now - at <= HOUR_MS);
|
|
46
|
+
timestamps.set(tag, kept);
|
|
47
|
+
return kept;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
/**
|
|
52
|
+
* Records one presented notification and reports if the threshold is crossed.
|
|
53
|
+
*
|
|
54
|
+
* Always returns after recording — the caller delivers regardless of what
|
|
55
|
+
* this says.
|
|
56
|
+
*/
|
|
57
|
+
record(now: number, ownerTag = '*'): BurstStatus {
|
|
58
|
+
const kept = prune(ownerTag, now);
|
|
59
|
+
kept.push(now);
|
|
60
|
+
|
|
61
|
+
const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
|
|
62
|
+
const lastHour = kept.length;
|
|
63
|
+
const exceeded = lastMinute > threshold.perMinute || lastHour > threshold.perHour;
|
|
64
|
+
|
|
65
|
+
if (exceeded) {
|
|
66
|
+
const last = reportedAt.get(ownerTag) ?? 0;
|
|
67
|
+
// One report per minute per tag; a sustained burst is one condition,
|
|
68
|
+
// not a hundred.
|
|
69
|
+
if (now - last >= MINUTE_MS) {
|
|
70
|
+
reportedAt.set(ownerTag, now);
|
|
71
|
+
emit(
|
|
72
|
+
DIAGNOSTIC_CODES.BURST_THRESHOLD_EXCEEDED,
|
|
73
|
+
`Abnormal notification volume: ${lastMinute} in the last minute and ${lastHour} in the last hour (thresholds ${threshold.perMinute}/minute, ${threshold.perHour}/hour). Every notification was still delivered — the package throttles nothing.`,
|
|
74
|
+
{ ownerTag, lastMinute, lastHour, threshold },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { ownerTag, lastMinute, lastHour, exceeded };
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
status(now: number, ownerTag = '*'): BurstStatus {
|
|
83
|
+
const kept = prune(ownerTag, now);
|
|
84
|
+
const lastMinute = kept.filter(at => now - at <= MINUTE_MS).length;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ownerTag,
|
|
88
|
+
lastMinute,
|
|
89
|
+
lastHour: kept.length,
|
|
90
|
+
exceeded: lastMinute > threshold.perMinute || kept.length > threshold.perHour,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
reset(): void {
|
|
95
|
+
timestamps.clear();
|
|
96
|
+
reportedAt.clear();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type BurstDetector = ReturnType<typeof createBurstDetector>;
|
|
@@ -1,72 +1,81 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The structured event stream.
|
|
3
|
-
*
|
|
4
|
-
* With no sink attached the package emits nothing and buffers nothing. A
|
|
5
|
-
* library that writes to the console in production has decided something for
|
|
6
|
-
* its consumer; this one routes events wherever the app already sends its logs,
|
|
7
|
-
* or stays silent.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
readonly
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
this.
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The structured event stream.
|
|
3
|
+
*
|
|
4
|
+
* With no sink attached the package emits nothing and buffers nothing. A
|
|
5
|
+
* library that writes to the console in production has decided something for
|
|
6
|
+
* its consumer; this one routes events wherever the app already sends its logs,
|
|
7
|
+
* or stays silent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createDiagnosticChannel } from '@chipmobilesdk/rn-core';
|
|
11
|
+
|
|
12
|
+
import type { DiagnosticEvent, DiagnosticSeverity, EventSink } from '../contract/diagnostics';
|
|
13
|
+
import type { DiagnosticCode } from '../contract/errors';
|
|
14
|
+
import { isNonFailure } from '../contract/errors';
|
|
15
|
+
import { redactContext } from './redact';
|
|
16
|
+
|
|
17
|
+
export interface EmitterOptions {
|
|
18
|
+
readonly sink?: EventSink;
|
|
19
|
+
readonly verbose?: boolean;
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const channel = createDiagnosticChannel<DiagnosticCode>('rn-notification');
|
|
24
|
+
|
|
25
|
+
export class DiagnosticEmitter {
|
|
26
|
+
private sink?: EventSink;
|
|
27
|
+
private verbose: boolean;
|
|
28
|
+
private readonly now: () => number;
|
|
29
|
+
|
|
30
|
+
constructor(options: EmitterOptions = {}) {
|
|
31
|
+
this.sink = options.sink;
|
|
32
|
+
this.verbose = options.verbose ?? false;
|
|
33
|
+
this.now = options.now ?? (() => Date.now());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
setSink(sink: EventSink | undefined): void {
|
|
37
|
+
this.sink = sink;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setVerbose(verbose: boolean): void {
|
|
41
|
+
this.verbose = verbose;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get hasSink(): boolean {
|
|
45
|
+
return this.sink !== undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
emit(
|
|
49
|
+
code: DiagnosticCode,
|
|
50
|
+
message: string,
|
|
51
|
+
context?: Readonly<Record<string, unknown>>,
|
|
52
|
+
severity?: DiagnosticSeverity,
|
|
53
|
+
): void {
|
|
54
|
+
const resolvedSeverity = severity ?? (isNonFailure(code) ? 'info' : 'error');
|
|
55
|
+
// The app's SDK sinks (`installSdkSink`, CORE-08) never get content, even
|
|
56
|
+
// with verbose diagnostics on: verbose is for this package's own sink.
|
|
57
|
+
channel.emit(code, resolvedSeverity, redactContext(context, false));
|
|
58
|
+
|
|
59
|
+
// No sink means no work at all — not a buffer that grows until one appears.
|
|
60
|
+
if (this.sink === undefined) return;
|
|
61
|
+
|
|
62
|
+
const event: DiagnosticEvent = {
|
|
63
|
+
code,
|
|
64
|
+
severity: resolvedSeverity,
|
|
65
|
+
message,
|
|
66
|
+
at: this.now(),
|
|
67
|
+
context: redactContext(context, this.verbose),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
this.sink(event);
|
|
72
|
+
} catch {
|
|
73
|
+
// A failing sink is the consumer's problem and must never become ours.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Lifecycle transitions that are not failures: scheduled, delivered, reconciled. */
|
|
78
|
+
info(code: DiagnosticCode, message: string, context?: Readonly<Record<string, unknown>>): void {
|
|
79
|
+
this.emit(code, message, context, 'info');
|
|
80
|
+
}
|
|
81
|
+
}
|