Skip to content

Commit 7322771

Browse files
feature: cache
change: giveaway storeIntoDatabase returns query promise change: some jsdoc to have @link
1 parent 1062579 commit 7322771

File tree

9 files changed

+173
-58
lines changed

9 files changed

+173
-58
lines changed

development/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ globalThis.logger = {
1212
// use environment variables
1313
import { resolve } from 'node:path';
1414
import { setup } from '@skyra/env-utilities';
15-
import { DB } from '@/database/database';
15+
import { GiveawayService } from '@/services/giveaway/giveaway-service';
1616
const DotenvConfigOutput = setup({ path: resolve(__dirname, '..', '.env') });
1717
console.log('DOTENV_CONFIG_OUTPUT:', DotenvConfigOutput, '\n\n');
1818
// --- Start custom code
1919

2020
async function main() {
21-
DB;
22-
await new Promise((resolve) => setTimeout(resolve, 10_000));
23-
return;
21+
console.log('----- Starting -----');
22+
await new GiveawayService().initialize();
2423
}
2524

2625
void main();

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@sapphire/time-utilities": "^1.7.11",
4040
"@skyra/env-utilities": "^1.3.0",
4141
"better-sqlite3": "^9.2.2",
42+
"cache-manager": "^5.3.2",
4243
"cheerio": "^1.0.0-rc.12",
4344
"colorette": "^2.0.20",
4445
"cronstrue": "^2.47.0",

src/commands/giveaways/fetch-giveaways.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ export class FetchGiveawaysCommand extends Command {
4444
await interaction.editReply('Will search for new giveaways');
4545
let giveawayService = await new GiveawayService().initialize();
4646
if (!unfiltered) {
47-
giveawayService = await giveawayService.filterGiveaways(channel);
47+
giveawayService =
48+
await giveawayService.filterGiveawaysWithChannel(channel);
4849
}
4950

5051
const giveawayStatus = await giveawayService.sendGiveaways(channel);

src/jobs/giveaways.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export class GiveawayNotifier extends BaseCronJob {
4242
return;
4343
}
4444

45-
const filteredService = await giveawayService.filterGiveaways(channel);
45+
const filteredService =
46+
await giveawayService.filterGiveawaysWithChannel(channel);
4647
await filteredService.sendGiveaways(channel);
4748
}
4849
}

src/services/base-service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { LogLevel } from '@sapphire/framework';
2+
3+
export abstract class BaseService {
4+
protected get logHeader() {
5+
return `Service[${this.constructor.name}]`;
6+
}
7+
protected log(message: string, logLevel: LogLevel = LogLevel.Info) {
8+
const formattedMessage = `${this.logHeader}: ${message}`;
9+
globalThis.logger.write(logLevel, formattedMessage);
10+
}
11+
}

src/services/giveaway/giveaway-service.ts

Lines changed: 91 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import grabFreeGamesGiveawaySite from './site-fetchers/grab-free-games';
2-
import grabFreeGamesSteamGiveawaySite from './site-fetchers/grab-free-games-steam.js';
2+
import grabFreeGamesSteamGiveawaySite from './site-fetchers/grab-free-games-steam';
33
import { TextBasedChannel } from 'discord.js';
44
import { BaseGiveawaySiteFetcher } from './site-fetchers/base';
55
import { Giveaway } from './giveaway';
66
import { GiveawayStatus, GiveawayStatusEnum } from './giveaway-status';
77
import { DB } from '@/database/database';
88
import { getChannelParentID } from '#lib/discord-fetch';
9-
import { LogLevel } from '@sapphire/framework';
9+
import { BaseService } from '../base-service';
10+
import { memoryStore } from 'cache-manager';
11+
import { CacheWrapper } from '@/utilities/cache-wrapper';
1012

1113
type TAvailableSite = 'GrabFreeGames' | 'GrabFreeGamesSteam';
1214
type TSiteParsers = Record<TAvailableSite, BaseGiveawaySiteFetcher>;
@@ -15,44 +17,54 @@ const giveawayFetchers: TSiteParsers = {
1517
GrabFreeGamesSteam: grabFreeGamesSteamGiveawaySite,
1618
};
1719

18-
export class GiveawayService {
20+
interface CacheMap {
21+
giveaways: Giveaway[];
22+
}
23+
const cache = new CacheWrapper<CacheMap>(memoryStore(), {
24+
max: 2,
25+
ttl: 900_000,
26+
});
27+
28+
/**
29+
* Giveaway service for fetching, filtering, sending giveaways
30+
*
31+
* Will use cache on construction, to fetch
32+
*/
33+
export class GiveawayService extends BaseService {
1934
giveaways: Giveaway[];
2035
latestStatus?: GiveawayStatus;
2136

2237
/**
23-
* Please use `.initialize` if `initialGiveaways` not supplied
38+
* Please use {@link GiveawayService.initialize} if {@link initialGiveaways} not supplied
2439
* @param initialGiveaways **NB:** not a deep copy
2540
*/
2641
constructor(initialGiveaways?: Giveaway[] | undefined) {
42+
super();
2743
this.giveaways = initialGiveaways || [];
2844
}
2945

30-
// Make sure to put into base class if will ever make one
31-
protected get logHeader() {
32-
return `Service[${this.constructor.name}]`;
33-
}
34-
protected log(message: string, logLevel: LogLevel = LogLevel.Info) {
35-
const formattedMessage = `${this.logHeader}: ${message}`;
36-
globalThis.logger.write(logLevel, formattedMessage);
37-
}
38-
3946
/**
4047
* Fetches giveaways if not supplied on construction
4148
*/
42-
async initialize() {
43-
if (this.giveaways.length === 0) {
44-
const fetchResult = await this.fetchGiveaways();
45-
if (fetchResult instanceof GiveawayStatus) {
46-
this.latestStatus = fetchResult;
47-
} else {
48-
this.giveaways = fetchResult;
49-
}
49+
async initialize(): Promise<this> {
50+
if (this.giveaways.length > 0) return this;
51+
const cachedGiveaways = await cache.get('giveaways');
52+
if (cachedGiveaways) {
53+
this.giveaways = cachedGiveaways;
54+
return this;
55+
}
56+
57+
// `this.fetchGiveaways()` already sets `this.giveaways`
58+
const fetchResult = await this.fetchGiveaways();
59+
if (!(fetchResult instanceof GiveawayStatus)) {
60+
void cache.set('giveaways', fetchResult);
5061
}
5162
return this;
5263
}
5364

5465
/**
5566
* Fetches giveaways from old to new
67+
* and stores to {@link GiveawayService.giveaways}
5668
*
5769
* **Note:** old to new is **not** guaranteed -- giveaways are just reversed\
5870
* it is assumed that website displays content from new to old
@@ -61,27 +73,44 @@ export class GiveawayService {
6173
this.latestStatus = undefined;
6274
const sources = Object.keys(giveawayFetchers);
6375
for (const sourceKey of sources) {
64-
const source = giveawayFetchers[sourceKey as TAvailableSite];
65-
const giveaways = await source
66-
.getGiveaways()
67-
.catch((error: Error) =>
68-
globalThis.logger.error(error, `${sourceKey}: FAILED`)
69-
);
70-
if (!giveaways || giveaways.length === 0) continue;
71-
72-
// might be moved into giveawayFetcher
73-
this.giveaways = giveaways.reverse();
74-
for (const x of this.giveaways) x.storeIntoDatabase();
75-
76-
this.log(`Fetched ${this.giveaways.length} giveaways from ${sourceKey}`);
76+
const giveaways = await this.fetchGiveawaysFromSource(
77+
sourceKey as TAvailableSite
78+
);
79+
if (!giveaways) continue;
80+
81+
this.giveaways = giveaways;
7782
return this.giveaways;
7883
}
79-
return new GiveawayStatus(GiveawayStatusEnum.NONE_FOUND, true);
84+
this.latestStatus = new GiveawayStatus(GiveawayStatusEnum.NONE_FOUND, true);
85+
return this.latestStatus;
86+
}
87+
88+
/**
89+
* Fetches giveaways from old to new
90+
*
91+
* **Note:** old to new is **not** guaranteed -- giveaways are just reversed\
92+
* it is assumed that website displays content from new to old
93+
*/
94+
async fetchGiveawaysFromSource(sourceSite: TAvailableSite) {
95+
const source = giveawayFetchers[sourceSite];
96+
let giveaways = await source
97+
.getGiveaways()
98+
.catch((error: Error) =>
99+
globalThis.logger.error(error, `${sourceSite}: FAILED`)
100+
);
101+
if (!giveaways || giveaways.length === 0) return false;
102+
103+
this.log(`Fetched ${giveaways.length} giveaways from ${sourceSite}`);
104+
giveaways = giveaways.reverse(); // to make it from `old to new`
105+
for (const x of giveaways) void x.storeIntoDatabase();
106+
return giveaways;
80107
}
81108

82-
async filterGiveaways(channel: TextBasedChannel) {
109+
/**
110+
* @param channel_container connected to `giveaways_channel_link.channel_container` and {@link getChannelParentID}.id
111+
*/
112+
async filterGiveaways(channel_container: string) {
83113
// might add a check for `this.latestStatus`
84-
const channelParent = getChannelParentID(channel);
85114
const giveawayTitles = this.giveaways.map((x) => x.giveaway.title);
86115

87116
const query = DB.selectFrom('giveaways')
@@ -96,7 +125,7 @@ export class GiveawayService {
96125
.where(
97126
'giveaways_channel_link.channel_container',
98127
'=',
99-
channelParent.id
128+
channel_container
100129
)
101130
),
102131
])
@@ -109,36 +138,48 @@ export class GiveawayService {
109138
-1
110139
);
111140
const delta = filteredGiveaways.length - this.giveaways.length;
112-
this.log(`Filtered ${Math.abs(delta)} giveaways for ${channelParent.id}`);
141+
this.log(`Filtered ${Math.abs(delta)} giveaways for ${channel_container}`);
113142
// might be a good idea to initialize and then set NO_NEW status if delta 0
114143
return new GiveawayService(filteredGiveaways);
115144
}
116145

117-
async sendGiveaways(channel: TextBasedChannel) {
118-
if (this.giveaways.length === 0) {
119-
this.latestStatus = new GiveawayStatus(GiveawayStatusEnum.NO_NEW);
120-
return this.latestStatus;
121-
}
122-
146+
async filterGiveawaysWithChannel(channel: TextBasedChannel) {
147+
// might add a check for `this.latestStatus`
123148
const channelParent = getChannelParentID(channel);
124-
for (const giveaway of this.giveaways) {
125-
await giveaway.sendToChannel(channel);
126-
}
127-
this.log(`Sent ${this.giveaways.length} giveaways to ${channelParent.id}`);
149+
return this.filterGiveaways(channelParent.id);
150+
}
128151

152+
/**
153+
* @param channel_container connected to `giveaways_channel_link.channel_container` and {@link getChannelParentID}.id
154+
*/
155+
async storeSentGiveaways(channel_container: string) {
129156
const giveawayTitles = this.giveaways.map((x) => x.giveaway.title);
130157
const query = DB.replaceInto('giveaways_channel_link')
131158
.columns(['channel_container', 'giveaway_id'])
132159
.expression((eb) =>
133160
eb
134161
.selectFrom('giveaways')
135162
.select((eb) => [
136-
eb.val(channelParent.id).as('channel_container'),
163+
eb.val(channel_container).as('channel_container'),
137164
'giveaways.id',
138165
])
139166
.where('giveaways.title', 'in', giveawayTitles)
140167
);
141168
await query.execute();
169+
}
170+
171+
async sendGiveaways(channel: TextBasedChannel) {
172+
if (this.giveaways.length === 0) {
173+
this.latestStatus = new GiveawayStatus(GiveawayStatusEnum.NO_NEW);
174+
return this.latestStatus;
175+
}
176+
177+
const channelParent = getChannelParentID(channel);
178+
for (const giveaway of this.giveaways) {
179+
await giveaway.sendToChannel(channel);
180+
}
181+
this.log(`Sent ${this.giveaways.length} giveaways to ${channelParent.id}`);
182+
await this.storeSentGiveaways(channelParent.id);
142183

143184
this.latestStatus = new GiveawayStatus(GiveawayStatusEnum.SUCCESS);
144185
return this.latestStatus;

src/services/giveaway/giveaway.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,6 @@ export class Giveaway {
6060
oc.doUpdateSet({ last_ping_at: sql`CURRENT_TIMESTAMP` })
6161
)
6262
.values({ title, url });
63-
void query.execute();
63+
return query.execute();
6464
}
6565
}

src/utilities/cache-wrapper.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import {
3+
MemoryCache,
4+
MemoryConfig,
5+
MemoryStore,
6+
createCache,
7+
memoryStore,
8+
} from 'cache-manager';
9+
10+
/**
11+
* Initializes cache wrapper with {@link memoryStore}
12+
*/
13+
export function makeMemoryStoreCacheWrapper(config?: MemoryConfig) {
14+
return new CacheWrapper(memoryStore(), config);
15+
}
16+
17+
/** Wrapper for cache-manager -- mostly for autocomplete */
18+
export class CacheWrapper<TypeMap extends Record<string, any>> {
19+
cache: MemoryCache;
20+
constructor(store: MemoryStore, config?: MemoryConfig) {
21+
this.cache = createCache(store, config);
22+
}
23+
24+
get<KEY extends keyof TypeMap & string>(key: KEY) {
25+
return this.cache.get<TypeMap[KEY]>(key);
26+
}
27+
28+
set<KEY extends keyof TypeMap & string>(
29+
key: KEY,
30+
value: TypeMap[KEY],
31+
ttl?: number
32+
) {
33+
return this.cache.set(key, value, ttl);
34+
}
35+
}

yarn.lock

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,17 @@ __metadata:
14351435
languageName: node
14361436
linkType: hard
14371437

1438+
"cache-manager@npm:^5.3.2":
1439+
version: 5.3.2
1440+
resolution: "cache-manager@npm:5.3.2"
1441+
dependencies:
1442+
lodash.clonedeep: "npm:^4.5.0"
1443+
lru-cache: "npm:^10.1.0"
1444+
promise-coalesce: "npm:^1.1.2"
1445+
checksum: 4120c4c8f1d2d9658fe854e0539e9c58001f53b685b3d8325b8481e5ccf73b665598c87c7286ba04502910cab52e17ab2a43b848dca968787cb2c7ddc0ba2114
1446+
languageName: node
1447+
linkType: hard
1448+
14381449
"cacheable-lookup@npm:^5.0.3":
14391450
version: 5.0.4
14401451
resolution: "cacheable-lookup@npm:5.0.4"
@@ -1893,6 +1904,7 @@ __metadata:
18931904
"@typescript-eslint/eslint-plugin": "npm:^6.18.1"
18941905
"@typescript-eslint/parser": "npm:^6.18.1"
18951906
better-sqlite3: "npm:^9.2.2"
1907+
cache-manager: "npm:^5.3.2"
18961908
cheerio: "npm:^1.0.0-rc.12"
18971909
colorette: "npm:^2.0.20"
18981910
cronstrue: "npm:^2.47.0"
@@ -3723,6 +3735,13 @@ __metadata:
37233735
languageName: node
37243736
linkType: hard
37253737

3738+
"lodash.clonedeep@npm:^4.5.0":
3739+
version: 4.5.0
3740+
resolution: "lodash.clonedeep@npm:4.5.0"
3741+
checksum: 957ed243f84ba6791d4992d5c222ffffca339a3b79dbe81d2eaf0c90504160b500641c5a0f56e27630030b18b8e971ea10b44f928a977d5ced3c8948841b555f
3742+
languageName: node
3743+
linkType: hard
3744+
37263745
"lodash.merge@npm:^4.6.2":
37273746
version: 4.6.2
37283747
resolution: "lodash.merge@npm:4.6.2"
@@ -3751,7 +3770,7 @@ __metadata:
37513770
languageName: node
37523771
linkType: hard
37533772

3754-
"lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0":
3773+
"lru-cache@npm:^10.0.1, lru-cache@npm:^10.1.0, lru-cache@npm:^9.1.1 || ^10.0.0":
37553774
version: 10.1.0
37563775
resolution: "lru-cache@npm:10.1.0"
37573776
checksum: 207278d6fa711fb1f94a0835d4d4737441d2475302482a14785b10515e4c906a57ebf9f35bf060740c9560e91c7c1ad5a04fd7ed030972a9ba18bce2a228e95b
@@ -4759,6 +4778,13 @@ __metadata:
47594778
languageName: node
47604779
linkType: hard
47614780

4781+
"promise-coalesce@npm:^1.1.2":
4782+
version: 1.1.2
4783+
resolution: "promise-coalesce@npm:1.1.2"
4784+
checksum: 6f951b5db40ca78d09ad0f72adea0a2e9c5bdb0fc5b3ef218c611e2191c6ce2e9f752f815630fd07875dd1c9f04cc7327d6bada04b662dce986ce86ab88c2d5e
4785+
languageName: node
4786+
linkType: hard
4787+
47624788
"promise-retry@npm:^2.0.1":
47634789
version: 2.0.1
47644790
resolution: "promise-retry@npm:2.0.1"

0 commit comments

Comments
 (0)