Skip to content

Commit 27c8d2a

Browse files
authored
feat: implement caching for country data with gzip compression (#26)
* feat: implement caching for country data with gzip compression * feat: update SonarCloud action to use SONAR_TOKEN from secrets * Refactor countries service and DTOs for improved structure and readability - Simplified the CountriesService by extracting common logic into helper functions. - Introduced GeoAreaDto as a base class for RegionDto and SubregionDto to reduce redundancy. - Updated entity definitions to reflect the new structure, including renaming RegionEntity to GeoAreaEntity. - Added new helper functions for parsing exclusion options and simplifying country/state entities. - Created comprehensive unit tests for new and existing functionality, ensuring robust coverage. - Implemented mock data fixtures for consistent testing across various modules. * feat: add unit tests for database initialization, provider, and caching services * refactor: update string normalization method and streamline imports in country repository
1 parent ea0b4d7 commit 27c8d2a

48 files changed

Lines changed: 1646 additions & 552 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.eslintrc.js

Lines changed: 0 additions & 26 deletions
This file was deleted.

.github/workflows/main.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,13 @@ jobs:
150150
name: coverage
151151
path: coverage/
152152

153-
- uses: SonarSource/sonarcloud-github-action@v4
153+
- uses: SonarSource/sonarqube-scan-action@v7
154+
env:
155+
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
154156
with:
155157
args: >
156158
-Dsonar.projectKey=${{ secrets.SONAR_KEY_RC }}
157159
-Dsonar.organization=${{ secrets.SONAR_ORGANIZATION }}
158160
-Dsonar.sources=src
159161
-Dsonar.tests=test
160162
-Dsonar.javascript.lcov.reportPaths=coverage/lcov.info
161-
-Dsonar.host.url=https://sonarcloud.io
162-
env:
163-
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_RC }}

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,6 @@ pids
5454
*.sqlite3-journal
5555
*.sqlite3-wal
5656
*.sqlite3-shm
57+
58+
# Static JSON cache (generated on boot)
59+
/data/cache

eslint.config.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import eslint from '@eslint/js';
2+
import eslintConfigPrettier from 'eslint-config-prettier';
3+
import eslintPluginPrettier from 'eslint-plugin-prettier/recommended';
4+
import tseslint from 'typescript-eslint';
5+
6+
export default tseslint.config(
7+
{
8+
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'data/**', 'eslint.config.mjs'],
9+
},
10+
11+
eslint.configs.recommended,
12+
13+
...tseslint.configs.recommended,
14+
15+
eslintConfigPrettier,
16+
eslintPluginPrettier,
17+
18+
{
19+
languageOptions: {
20+
parserOptions: {
21+
projectService: true,
22+
tsconfigRootDir: import.meta.dirname,
23+
},
24+
},
25+
rules: {
26+
'@typescript-eslint/interface-name-prefix': 'off',
27+
'@typescript-eslint/explicit-function-return-type': 'off',
28+
'@typescript-eslint/explicit-module-boundary-types': 'off',
29+
'@typescript-eslint/no-explicit-any': 'off',
30+
'max-len': ['error', { code: 120 }],
31+
},
32+
},
33+
);

package-lock.json

Lines changed: 28 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@
4848
"@types/express": "^5.0.0",
4949
"@types/jest": "^29.5.14",
5050
"@types/node": "^24.0.0",
51-
"@typescript-eslint/eslint-plugin": "^8.0.0",
52-
"@typescript-eslint/parser": "^8.0.0",
5351
"eslint": "^9.0.0",
5452
"eslint-config-prettier": "^10.0.0",
5553
"eslint-plugin-prettier": "^5.2.0",
@@ -58,7 +56,8 @@
5856
"ts-jest": "^29.3.0",
5957
"ts-node": "^10.9.2",
6058
"tsconfig-paths": "^4.2.0",
61-
"typescript": "^5.9.3"
59+
"typescript": "^5.9.3",
60+
"typescript-eslint": "^8.55.0"
6261
},
6362
"jest": {
6463
"moduleFileExtensions": [

src/database/database.initializer.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,6 @@ import { Kysely, sql } from 'kysely';
33
import { DATABASE_TOKEN } from './database.provider';
44
import { Database } from './database.types';
55

6-
/**
7-
* Validates the SQLite database on every application boot.
8-
*
9-
* 1. Checks that all required tables exist — fails fast if any are missing.
10-
* 2. Ensures all indexes exist (CREATE INDEX IF NOT EXISTS) on every boot.
11-
*/
126
@Injectable()
137
export class DatabaseInitializer implements OnModuleInit {
148
private readonly logger = new Logger(DatabaseInitializer.name);
@@ -21,8 +15,6 @@ export class DatabaseInitializer implements OnModuleInit {
2115
this.logger.log('Database validated — schema and indexes OK.');
2216
}
2317

24-
// ── Schema validation ───────────────────────────────────────
25-
2618
private async validateSchema(): Promise<void> {
2719
const required = ['regions', 'subregions', 'countries', 'states', 'cities'];
2820

@@ -42,24 +34,19 @@ export class DatabaseInitializer implements OnModuleInit {
4234
}
4335
}
4436

45-
// ── Index management (runs on EVERY boot) ───────────────────
46-
4737
private async ensureIndexes(): Promise<void> {
4838
const indexes = [
49-
// FK indexes
5039
'CREATE INDEX IF NOT EXISTS idx_subregions_region_id ON subregions(region_id)',
5140
'CREATE INDEX IF NOT EXISTS idx_countries_region_id ON countries(region_id)',
5241
'CREATE INDEX IF NOT EXISTS idx_countries_subregion_id ON countries(subregion_id)',
5342
'CREATE INDEX IF NOT EXISTS idx_states_country_id ON states(country_id)',
5443
'CREATE INDEX IF NOT EXISTS idx_cities_state_id ON cities(state_id)',
5544
'CREATE INDEX IF NOT EXISTS idx_cities_country_id ON cities(country_id)',
56-
// Name lookups (COLLATE NOCASE)
5745
'CREATE INDEX IF NOT EXISTS idx_regions_name ON regions(name COLLATE NOCASE)',
5846
'CREATE INDEX IF NOT EXISTS idx_subregions_name ON subregions(name COLLATE NOCASE)',
5947
'CREATE INDEX IF NOT EXISTS idx_countries_name ON countries(name COLLATE NOCASE)',
6048
'CREATE INDEX IF NOT EXISTS idx_states_name ON states(name COLLATE NOCASE)',
6149
'CREATE INDEX IF NOT EXISTS idx_cities_name ON cities(name COLLATE NOCASE)',
62-
// Common query patterns
6350
'CREATE INDEX IF NOT EXISTS idx_countries_capital ON countries(capital COLLATE NOCASE)',
6451
'CREATE INDEX IF NOT EXISTS idx_countries_iso2 ON countries(iso2)',
6552
'CREATE INDEX IF NOT EXISTS idx_countries_iso3 ON countries(iso3)',

src/database/database.provider.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export const DatabaseProvider: Provider = {
1515

1616
const native = new BetterSqlite3(dbPath);
1717

18-
// SQLite pragmas from .env
1918
native.pragma(`journal_mode = ${config.getOrThrow('SQLITE_JOURNAL_MODE')}`);
2019
native.pragma('foreign_keys = ON');
2120
native.pragma(`busy_timeout = ${config.getOrThrow('SQLITE_BUSY_TIMEOUT')}`);

src/database/database.types.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { Generated, Insertable, Selectable } from 'kysely';
22

3-
// ─── Table definitions ──────────────────────────────────────
4-
53
export interface RegionTable {
64
id: Generated<number>;
75
name: string;
@@ -87,8 +85,6 @@ export interface CityTable {
8785
wikiDataId: string | null;
8886
}
8987

90-
// ─── Database map ────────────────────────────────────────────
91-
9288
export interface Database {
9389
regions: RegionTable;
9490
subregions: SubregionTable;
@@ -97,16 +93,12 @@ export interface Database {
9793
cities: CityTable;
9894
}
9995

100-
// ─── Row types (what comes out of SELECT) ────────────────────
101-
10296
export type RegionRow = Selectable<RegionTable>;
10397
export type SubregionRow = Selectable<SubregionTable>;
10498
export type CountryRow = Selectable<CountryTable>;
10599
export type StateRow = Selectable<StateTable>;
106100
export type CityRow = Selectable<CityTable>;
107101

108-
// ─── Insert types ────────────────────────────────────────────
109-
110102
export type NewRegion = Insertable<RegionTable>;
111103
export type NewSubregion = Insertable<SubregionTable>;
112104
export type NewCountry = Insertable<CountryTable>;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { join } from 'node:path';
2+
import { ExcludeOption, ResponseType } from '../dto/country-query.dto';
3+
4+
export const CACHE_DIR = join(process.cwd(), 'data', 'cache');
5+
6+
export function getCacheFileName(type?: ResponseType, exclude?: ExcludeOption): string {
7+
const typePart = type === ResponseType.SIMPLE ? 'simple' : 'full';
8+
9+
let excludePart = '';
10+
if (exclude === ExcludeOption.STATES) excludePart = '-exclude-states';
11+
else if (exclude === ExcludeOption.CITIES) excludePart = '-exclude-cities';
12+
13+
return `countries-${typePart}${excludePart}.json.gz`;
14+
}
15+
16+
export const ALL_CACHE_COMBOS: { type?: ResponseType; exclude?: ExcludeOption }[] = [
17+
{ type: undefined, exclude: undefined },
18+
{ type: undefined, exclude: ExcludeOption.CITIES },
19+
{ type: undefined, exclude: ExcludeOption.STATES },
20+
{ type: ResponseType.SIMPLE, exclude: undefined },
21+
{ type: ResponseType.SIMPLE, exclude: ExcludeOption.CITIES },
22+
{ type: ResponseType.SIMPLE, exclude: ExcludeOption.STATES },
23+
];

0 commit comments

Comments
 (0)