campus-stats 0.4.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 +35 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/cache.d.ts +10 -0
- package/dist/cache.js +69 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +537 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.js +273 -0
- package/dist/data-bundle.d.ts +11 -0
- package/dist/data-bundle.js +17 -0
- package/dist/db/migrate.d.ts +6 -0
- package/dist/db/migrate.js +22 -0
- package/dist/db/sqlite-store.d.ts +12 -0
- package/dist/db/sqlite-store.js +344 -0
- package/dist/db/store-api.d.ts +9 -0
- package/dist/db/store-api.js +70 -0
- package/dist/fantasy.d.ts +36 -0
- package/dist/fantasy.js +152 -0
- package/dist/http.d.ts +25 -0
- package/dist/http.js +63 -0
- package/dist/identity/match.d.ts +22 -0
- package/dist/identity/match.js +106 -0
- package/dist/identity/propose.d.ts +8 -0
- package/dist/identity/propose.js +45 -0
- package/dist/identity/rules.d.ts +8 -0
- package/dist/identity/rules.js +17 -0
- package/dist/identity/store.d.ts +5 -0
- package/dist/identity/store.js +20 -0
- package/dist/identity/types.d.ts +32 -0
- package/dist/identity/types.js +8 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.js +4 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/injuries.d.ts +12 -0
- package/dist/injuries.js +15 -0
- package/dist/refs.d.ts +7 -0
- package/dist/refs.js +1 -0
- package/dist/scoring.d.ts +37 -0
- package/dist/scoring.js +56 -0
- package/dist/sources/fbref-map.d.ts +14 -0
- package/dist/sources/fbref-map.js +56 -0
- package/dist/sources/fbref-parse.d.ts +17 -0
- package/dist/sources/fbref-parse.js +87 -0
- package/dist/sources/fbref.d.ts +27 -0
- package/dist/sources/fbref.js +72 -0
- package/dist/sources/index.d.ts +3 -0
- package/dist/sources/index.js +2 -0
- package/dist/sources/statsbomb-player-stats.d.ts +63 -0
- package/dist/sources/statsbomb-player-stats.js +107 -0
- package/dist/sources/statsbomb.d.ts +50 -0
- package/dist/sources/statsbomb.js +170 -0
- package/dist/sources/types.d.ts +23 -0
- package/dist/sources/types.js +1 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-facing client: install `campus` and use this from a web API,
|
|
3
|
+
* serverless function, or Node backend. Browser bundles can import query
|
|
4
|
+
* helpers against an in-memory cache loaded via `fromBundle` / `fromCache`.
|
|
5
|
+
*/
|
|
6
|
+
import { emptyCache, loadCache, mergeSyncResult, saveCache, cachePath, } from "./cache.js";
|
|
7
|
+
import { dataBundleUrl, GITLAB_PROJECT_ID } from "./data-bundle.js";
|
|
8
|
+
import { syncFantasyBundle, updateCachedCompetitions, } from "./fantasy.js";
|
|
9
|
+
import { listInjuries, INJURIES_AVAILABLE, INJURIES_STATUS_MESSAGE } from "./injuries.js";
|
|
10
|
+
import { DEFAULT_FANTASY_RULES, scoreFantasyPoints, } from "./scoring.js";
|
|
11
|
+
function includesCI(haystack, needle) {
|
|
12
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
export class CampusClient {
|
|
15
|
+
cache;
|
|
16
|
+
persistPath;
|
|
17
|
+
constructor(cache, persistPath) {
|
|
18
|
+
this.cache = cache;
|
|
19
|
+
this.persistPath = persistPath;
|
|
20
|
+
}
|
|
21
|
+
/** Open local JSON cache (default `.campus/cache.json`). */
|
|
22
|
+
static async open(filePath = cachePath()) {
|
|
23
|
+
return new CampusClient(await loadCache(filePath), filePath);
|
|
24
|
+
}
|
|
25
|
+
/** Use an in-memory cache (e.g. after fetch in an API route). */
|
|
26
|
+
static fromCache(cache) {
|
|
27
|
+
return new CampusClient(cache);
|
|
28
|
+
}
|
|
29
|
+
/** Download the cron-published bundle and optionally merge with local cache. */
|
|
30
|
+
static async fromBundle(options = {}) {
|
|
31
|
+
const url = options.url ?? dataBundleUrl(GITLAB_PROJECT_ID);
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
throw new Error(`Failed to load data bundle (${res.status}) from ${url}. ` +
|
|
35
|
+
"Publish via GitLab CI schedule (docs/cron.md) or call syncFantasy() once.");
|
|
36
|
+
}
|
|
37
|
+
const remote = (await res.json());
|
|
38
|
+
const incoming = {
|
|
39
|
+
...emptyCache(),
|
|
40
|
+
competitions: remote.competitions ?? [],
|
|
41
|
+
seasons: remote.seasons ?? [],
|
|
42
|
+
teams: remote.teams ?? [],
|
|
43
|
+
matches: remote.matches ?? [],
|
|
44
|
+
identities: remote.identities ?? [],
|
|
45
|
+
players: remote.players ?? [],
|
|
46
|
+
playerMatchStats: remote.playerMatchStats ?? [],
|
|
47
|
+
lineups: remote.lineups ?? [],
|
|
48
|
+
injuries: remote.injuries ?? [],
|
|
49
|
+
};
|
|
50
|
+
const base = options.mergeWith ?? emptyCache();
|
|
51
|
+
const merged = mergeSyncResult(base, {
|
|
52
|
+
competitions: incoming.competitions,
|
|
53
|
+
seasons: incoming.seasons,
|
|
54
|
+
teams: incoming.teams,
|
|
55
|
+
matches: incoming.matches,
|
|
56
|
+
players: incoming.players,
|
|
57
|
+
playerMatchStats: incoming.playerMatchStats,
|
|
58
|
+
lineups: incoming.lineups,
|
|
59
|
+
injuries: incoming.injuries,
|
|
60
|
+
});
|
|
61
|
+
merged.identities =
|
|
62
|
+
base.identities.length > 0 ? base.identities : incoming.identities;
|
|
63
|
+
const client = new CampusClient(merged, options.persistPath);
|
|
64
|
+
if (options.persistPath)
|
|
65
|
+
await client.save();
|
|
66
|
+
return client;
|
|
67
|
+
}
|
|
68
|
+
/** Snapshot of normalized data (safe to serialize to the browser). */
|
|
69
|
+
get data() {
|
|
70
|
+
return this.cache;
|
|
71
|
+
}
|
|
72
|
+
async save(filePath = this.persistPath ?? cachePath()) {
|
|
73
|
+
await saveCache(this.cache, filePath);
|
|
74
|
+
}
|
|
75
|
+
/** Sync every women's StatsBomb competition (clubs + national teams). */
|
|
76
|
+
async syncFantasy(options = {}) {
|
|
77
|
+
const result = await syncFantasyBundle(options);
|
|
78
|
+
this.cache = mergeSyncResult(this.cache, result);
|
|
79
|
+
if (this.persistPath)
|
|
80
|
+
await this.save();
|
|
81
|
+
return this.cache;
|
|
82
|
+
}
|
|
83
|
+
/** Re-sync competitions already present in the cache. */
|
|
84
|
+
async update(options = {}) {
|
|
85
|
+
const result = await updateCachedCompetitions(this.cache, options);
|
|
86
|
+
this.cache = mergeSyncResult(this.cache, result);
|
|
87
|
+
if (this.persistPath)
|
|
88
|
+
await this.save();
|
|
89
|
+
return this.cache;
|
|
90
|
+
}
|
|
91
|
+
/** Pull the published cron bundle into this client. */
|
|
92
|
+
async pull(url) {
|
|
93
|
+
const next = await CampusClient.fromBundle({
|
|
94
|
+
url,
|
|
95
|
+
mergeWith: this.cache,
|
|
96
|
+
persistPath: this.persistPath,
|
|
97
|
+
});
|
|
98
|
+
this.cache = next.data;
|
|
99
|
+
return this.cache;
|
|
100
|
+
}
|
|
101
|
+
competitions(filter) {
|
|
102
|
+
return this.cache.competitions.filter((c) => {
|
|
103
|
+
if (filter?.name && !includesCI(c.name, filter.name))
|
|
104
|
+
return false;
|
|
105
|
+
if (filter?.international != null &&
|
|
106
|
+
Boolean(c.international) !== filter.international) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
seasons(filter) {
|
|
113
|
+
const competitionIds = filter?.competition
|
|
114
|
+
? new Set(this.competitions({ name: filter.competition }).map((c) => c.id))
|
|
115
|
+
: null;
|
|
116
|
+
return this.cache.seasons.filter((s) => competitionIds ? competitionIds.has(s.competitionId) : true);
|
|
117
|
+
}
|
|
118
|
+
teams(filter) {
|
|
119
|
+
let teamIds = null;
|
|
120
|
+
if (filter?.competition) {
|
|
121
|
+
const comps = this.competitions({ name: filter.competition });
|
|
122
|
+
const ids = new Set(comps.map((c) => c.id));
|
|
123
|
+
teamIds = new Set();
|
|
124
|
+
for (const m of this.cache.matches) {
|
|
125
|
+
if (!ids.has(m.competitionId))
|
|
126
|
+
continue;
|
|
127
|
+
teamIds.add(m.homeTeamId);
|
|
128
|
+
teamIds.add(m.awayTeamId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return this.cache.teams.filter((t) => {
|
|
132
|
+
if (teamIds && !teamIds.has(t.id))
|
|
133
|
+
return false;
|
|
134
|
+
if (filter?.kind && t.kind !== filter.kind)
|
|
135
|
+
return false;
|
|
136
|
+
if (filter?.name && !includesCI(t.name, filter.name))
|
|
137
|
+
return false;
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
matches(filter) {
|
|
142
|
+
const comps = filter?.competition
|
|
143
|
+
? this.competitions({ name: filter.competition })
|
|
144
|
+
: this.cache.competitions;
|
|
145
|
+
const competitionIds = new Set(comps.map((c) => c.id));
|
|
146
|
+
let seasonIds = null;
|
|
147
|
+
if (filter?.season) {
|
|
148
|
+
seasonIds = new Set(this.cache.seasons
|
|
149
|
+
.filter((s) => competitionIds.has(s.competitionId) &&
|
|
150
|
+
s.name.toLowerCase() === filter.season.toLowerCase())
|
|
151
|
+
.map((s) => s.id));
|
|
152
|
+
}
|
|
153
|
+
const teamById = new Map(this.cache.teams.map((t) => [t.id, t]));
|
|
154
|
+
return this.cache.matches.filter((m) => {
|
|
155
|
+
if (!competitionIds.has(m.competitionId))
|
|
156
|
+
return false;
|
|
157
|
+
if (seasonIds && !seasonIds.has(m.seasonId))
|
|
158
|
+
return false;
|
|
159
|
+
if (filter?.team) {
|
|
160
|
+
const home = teamById.get(m.homeTeamId)?.name ?? "";
|
|
161
|
+
const away = teamById.get(m.awayTeamId)?.name ?? "";
|
|
162
|
+
if (!includesCI(home, filter.team) && !includesCI(away, filter.team)) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
players(filter) {
|
|
170
|
+
let playerIds = null;
|
|
171
|
+
if (filter?.team) {
|
|
172
|
+
const teams = this.teams({ name: filter.team });
|
|
173
|
+
const teamIds = new Set(teams.map((t) => t.id));
|
|
174
|
+
playerIds = new Set(this.cache.playerMatchStats
|
|
175
|
+
.filter((s) => teamIds.has(s.teamId))
|
|
176
|
+
.map((s) => s.playerId));
|
|
177
|
+
}
|
|
178
|
+
return this.cache.players.filter((p) => {
|
|
179
|
+
if (filter?.name && !includesCI(p.name, filter.name))
|
|
180
|
+
return false;
|
|
181
|
+
if (playerIds && !playerIds.has(p.id))
|
|
182
|
+
return false;
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
playerStats(filter) {
|
|
187
|
+
const matchIds = new Set(this.matches({ competition: filter?.competition }).map((m) => m.id));
|
|
188
|
+
const playerById = new Map(this.cache.players.map((p) => [p.id, p]));
|
|
189
|
+
const teamById = new Map(this.cache.teams.map((t) => [t.id, t]));
|
|
190
|
+
return this.cache.playerMatchStats.filter((s) => {
|
|
191
|
+
if (!matchIds.has(s.matchId))
|
|
192
|
+
return false;
|
|
193
|
+
if (filter?.matchId && s.matchId !== filter.matchId)
|
|
194
|
+
return false;
|
|
195
|
+
if (filter?.player) {
|
|
196
|
+
const name = playerById.get(s.playerId)?.name ?? "";
|
|
197
|
+
if (!includesCI(name, filter.player))
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
if (filter?.team) {
|
|
201
|
+
const name = teamById.get(s.teamId)?.name ?? "";
|
|
202
|
+
if (!includesCI(name, filter.team))
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
lineups(filter) {
|
|
209
|
+
const teamById = new Map(this.cache.teams.map((t) => [t.id, t]));
|
|
210
|
+
return this.cache.lineups.filter((l) => {
|
|
211
|
+
if (filter?.matchId && l.matchId !== filter.matchId)
|
|
212
|
+
return false;
|
|
213
|
+
if (filter?.team) {
|
|
214
|
+
const name = teamById.get(l.teamId)?.name ?? "";
|
|
215
|
+
if (!includesCI(name, filter.team))
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** Season squad = distinct players who appeared in lineups for a team. */
|
|
222
|
+
squad(filter) {
|
|
223
|
+
const matchIds = new Set(this.matches({
|
|
224
|
+
competition: filter.competition,
|
|
225
|
+
season: filter.season,
|
|
226
|
+
team: filter.team,
|
|
227
|
+
}).map((m) => m.id));
|
|
228
|
+
const team = this.teams({
|
|
229
|
+
competition: filter.competition,
|
|
230
|
+
name: filter.team,
|
|
231
|
+
})[0];
|
|
232
|
+
if (!team)
|
|
233
|
+
return [];
|
|
234
|
+
const playerById = new Map(this.cache.players.map((p) => [p.id, p]));
|
|
235
|
+
const counts = new Map();
|
|
236
|
+
for (const l of this.cache.lineups) {
|
|
237
|
+
if (l.teamId !== team.id || !matchIds.has(l.matchId))
|
|
238
|
+
continue;
|
|
239
|
+
counts.set(l.playerId, (counts.get(l.playerId) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
return [...counts.entries()]
|
|
242
|
+
.map(([playerId, appearances]) => ({
|
|
243
|
+
playerId,
|
|
244
|
+
playerName: playerById.get(playerId)?.name ?? playerId,
|
|
245
|
+
teamId: team.id,
|
|
246
|
+
teamName: team.name,
|
|
247
|
+
appearances,
|
|
248
|
+
}))
|
|
249
|
+
.sort((a, b) => a.playerName.localeCompare(b.playerName));
|
|
250
|
+
}
|
|
251
|
+
fantasyPoints(filter) {
|
|
252
|
+
const comps = filter?.competition
|
|
253
|
+
? this.competitions({ name: filter.competition })
|
|
254
|
+
: [];
|
|
255
|
+
const competitionId = comps[0]?.id;
|
|
256
|
+
const players = filter?.player ? this.players({ name: filter.player }) : [];
|
|
257
|
+
const teams = filter?.team ? this.teams({ name: filter.team }) : [];
|
|
258
|
+
return scoreFantasyPoints(this.cache, {
|
|
259
|
+
competitionId,
|
|
260
|
+
matchId: filter?.matchId,
|
|
261
|
+
playerId: players[0]?.id,
|
|
262
|
+
teamId: teams[0]?.id,
|
|
263
|
+
rules: filter?.rules ?? DEFAULT_FANTASY_RULES,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
injuries() {
|
|
267
|
+
return {
|
|
268
|
+
available: INJURIES_AVAILABLE,
|
|
269
|
+
message: INJURIES_STATUS_MESSAGE,
|
|
270
|
+
records: listInjuries(this.cache),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published women's fantasy data bundle (refreshed by GitLab CI cron).
|
|
3
|
+
* Generic Package Registry paths under project 86296665.
|
|
4
|
+
*/
|
|
5
|
+
export declare const GITLAB_PROJECT_ID = "86296665";
|
|
6
|
+
export declare const DATA_BUNDLE_PACKAGE = "campus-data";
|
|
7
|
+
export declare const DATA_BUNDLE_VERSION = "latest";
|
|
8
|
+
export declare const DATA_BUNDLE_FILE = "cache.json";
|
|
9
|
+
/** Absolute URL of the periodically refreshed cache snapshot. */
|
|
10
|
+
export declare function dataBundleUrl(projectId?: string, version?: string): string;
|
|
11
|
+
export declare function dataBundleMetaUrl(projectId?: string, version?: string): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published women's fantasy data bundle (refreshed by GitLab CI cron).
|
|
3
|
+
* Generic Package Registry paths under project 86296665.
|
|
4
|
+
*/
|
|
5
|
+
export const GITLAB_PROJECT_ID = "86296665";
|
|
6
|
+
export const DATA_BUNDLE_PACKAGE = "campus-data";
|
|
7
|
+
export const DATA_BUNDLE_VERSION = "latest";
|
|
8
|
+
export const DATA_BUNDLE_FILE = "cache.json";
|
|
9
|
+
/** Absolute URL of the periodically refreshed cache snapshot. */
|
|
10
|
+
export function dataBundleUrl(projectId = GITLAB_PROJECT_ID, version = DATA_BUNDLE_VERSION) {
|
|
11
|
+
return (`https://gitlab.com/api/v4/projects/${projectId}/packages/generic/` +
|
|
12
|
+
`${DATA_BUNDLE_PACKAGE}/${version}/${DATA_BUNDLE_FILE}`);
|
|
13
|
+
}
|
|
14
|
+
export function dataBundleMetaUrl(projectId = GITLAB_PROJECT_ID, version = DATA_BUNDLE_VERSION) {
|
|
15
|
+
return (`https://gitlab.com/api/v4/projects/${projectId}/packages/generic/` +
|
|
16
|
+
`${DATA_BUNDLE_PACKAGE}/${version}/meta.json`);
|
|
17
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const DEFAULT_DB_PATH = ".campus/campus.sqlite";
|
|
2
|
+
/** Import JSON cache into SQLite (upsert). No-op if JSON cache missing. */
|
|
3
|
+
export declare function migrateJsonCacheToSqlite(dbPath?: string, jsonPath?: string): Promise<{
|
|
4
|
+
migrated: boolean;
|
|
5
|
+
path: string;
|
|
6
|
+
}>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { cachePath, loadCache } from "../cache.js";
|
|
3
|
+
import { SqliteStore } from "./sqlite-store.js";
|
|
4
|
+
export const DEFAULT_DB_PATH = ".campus/campus.sqlite";
|
|
5
|
+
/** Import JSON cache into SQLite (upsert). No-op if JSON cache missing. */
|
|
6
|
+
export async function migrateJsonCacheToSqlite(dbPath = DEFAULT_DB_PATH, jsonPath = cachePath()) {
|
|
7
|
+
try {
|
|
8
|
+
await access(jsonPath);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return { migrated: false, path: dbPath };
|
|
12
|
+
}
|
|
13
|
+
const cache = await loadCache(jsonPath);
|
|
14
|
+
const store = new SqliteStore(dbPath);
|
|
15
|
+
try {
|
|
16
|
+
store.upsertCache(cache);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
store.close();
|
|
20
|
+
}
|
|
21
|
+
return { migrated: true, path: dbPath };
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CampusCache } from "../types.js";
|
|
2
|
+
export declare const SCHEMA_SQL = "\nCREATE TABLE IF NOT EXISTS competitions (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n country TEXT,\n gender TEXT NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS seasons (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n competition_id TEXT NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS teams (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n country TEXT,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS matches (\n id TEXT PRIMARY KEY,\n competition_id TEXT NOT NULL,\n season_id TEXT NOT NULL,\n date TEXT,\n home_team_id TEXT NOT NULL,\n away_team_id TEXT NOT NULL,\n home_score INTEGER,\n away_score INTEGER,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS identities (\n id TEXT PRIMARY KEY,\n kind TEXT NOT NULL,\n name TEXT NOT NULL,\n aliases_json TEXT NOT NULL,\n sources_json TEXT NOT NULL,\n confidence TEXT NOT NULL,\n status TEXT NOT NULL,\n competition_hint TEXT\n);\nCREATE TABLE IF NOT EXISTS players (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n nickname TEXT,\n country TEXT,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS player_match_stats (\n id TEXT PRIMARY KEY,\n match_id TEXT NOT NULL,\n player_id TEXT NOT NULL,\n team_id TEXT NOT NULL,\n minutes INTEGER,\n goals INTEGER NOT NULL,\n assists INTEGER NOT NULL,\n yellow_cards INTEGER NOT NULL,\n red_cards INTEGER NOT NULL,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS lineups (\n id TEXT PRIMARY KEY,\n match_id TEXT NOT NULL,\n team_id TEXT NOT NULL,\n player_id TEXT NOT NULL,\n started INTEGER NOT NULL,\n jersey_number INTEGER,\n sources_json TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS injuries (\n id TEXT PRIMARY KEY,\n player_id TEXT NOT NULL,\n team_id TEXT,\n status TEXT NOT NULL,\n description TEXT,\n from_date TEXT,\n to_date TEXT,\n sources_json TEXT NOT NULL\n);\n";
|
|
3
|
+
export declare class SqliteStore {
|
|
4
|
+
private readonly db;
|
|
5
|
+
constructor(dbPath: string);
|
|
6
|
+
close(): void;
|
|
7
|
+
replaceAll(cache: CampusCache): void;
|
|
8
|
+
/** Upsert entities from a CampusCache slice (merge semantics). */
|
|
9
|
+
upsertCache(cache: CampusCache): void;
|
|
10
|
+
loadCache(): CampusCache;
|
|
11
|
+
private insertCache;
|
|
12
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
export const SCHEMA_SQL = `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS competitions (
|
|
5
|
+
id TEXT PRIMARY KEY,
|
|
6
|
+
name TEXT NOT NULL,
|
|
7
|
+
country TEXT,
|
|
8
|
+
gender TEXT NOT NULL,
|
|
9
|
+
sources_json TEXT NOT NULL
|
|
10
|
+
);
|
|
11
|
+
CREATE TABLE IF NOT EXISTS seasons (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
name TEXT NOT NULL,
|
|
14
|
+
competition_id TEXT NOT NULL,
|
|
15
|
+
sources_json TEXT NOT NULL
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS teams (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
country TEXT,
|
|
21
|
+
sources_json TEXT NOT NULL
|
|
22
|
+
);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS matches (
|
|
24
|
+
id TEXT PRIMARY KEY,
|
|
25
|
+
competition_id TEXT NOT NULL,
|
|
26
|
+
season_id TEXT NOT NULL,
|
|
27
|
+
date TEXT,
|
|
28
|
+
home_team_id TEXT NOT NULL,
|
|
29
|
+
away_team_id TEXT NOT NULL,
|
|
30
|
+
home_score INTEGER,
|
|
31
|
+
away_score INTEGER,
|
|
32
|
+
sources_json TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS identities (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
kind TEXT NOT NULL,
|
|
37
|
+
name TEXT NOT NULL,
|
|
38
|
+
aliases_json TEXT NOT NULL,
|
|
39
|
+
sources_json TEXT NOT NULL,
|
|
40
|
+
confidence TEXT NOT NULL,
|
|
41
|
+
status TEXT NOT NULL,
|
|
42
|
+
competition_hint TEXT
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS players (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
name TEXT NOT NULL,
|
|
47
|
+
nickname TEXT,
|
|
48
|
+
country TEXT,
|
|
49
|
+
sources_json TEXT NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS player_match_stats (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
match_id TEXT NOT NULL,
|
|
54
|
+
player_id TEXT NOT NULL,
|
|
55
|
+
team_id TEXT NOT NULL,
|
|
56
|
+
minutes INTEGER,
|
|
57
|
+
goals INTEGER NOT NULL,
|
|
58
|
+
assists INTEGER NOT NULL,
|
|
59
|
+
yellow_cards INTEGER NOT NULL,
|
|
60
|
+
red_cards INTEGER NOT NULL,
|
|
61
|
+
sources_json TEXT NOT NULL
|
|
62
|
+
);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS lineups (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
match_id TEXT NOT NULL,
|
|
66
|
+
team_id TEXT NOT NULL,
|
|
67
|
+
player_id TEXT NOT NULL,
|
|
68
|
+
started INTEGER NOT NULL,
|
|
69
|
+
jersey_number INTEGER,
|
|
70
|
+
sources_json TEXT NOT NULL
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS injuries (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
player_id TEXT NOT NULL,
|
|
75
|
+
team_id TEXT,
|
|
76
|
+
status TEXT NOT NULL,
|
|
77
|
+
description TEXT,
|
|
78
|
+
from_date TEXT,
|
|
79
|
+
to_date TEXT,
|
|
80
|
+
sources_json TEXT NOT NULL
|
|
81
|
+
);
|
|
82
|
+
`;
|
|
83
|
+
function j(value) {
|
|
84
|
+
return JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
function parseJson(raw) {
|
|
87
|
+
return JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
export class SqliteStore {
|
|
90
|
+
db;
|
|
91
|
+
constructor(dbPath) {
|
|
92
|
+
// Lazy-load so --help and JSON-only flows do not touch experimental node:sqlite.
|
|
93
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
94
|
+
this.db = new DatabaseSync(dbPath);
|
|
95
|
+
this.db.exec(SCHEMA_SQL);
|
|
96
|
+
}
|
|
97
|
+
close() {
|
|
98
|
+
this.db.close();
|
|
99
|
+
}
|
|
100
|
+
replaceAll(cache) {
|
|
101
|
+
this.db.exec("BEGIN");
|
|
102
|
+
try {
|
|
103
|
+
for (const table of [
|
|
104
|
+
"competitions",
|
|
105
|
+
"seasons",
|
|
106
|
+
"teams",
|
|
107
|
+
"matches",
|
|
108
|
+
"identities",
|
|
109
|
+
"players",
|
|
110
|
+
"player_match_stats",
|
|
111
|
+
"lineups",
|
|
112
|
+
"injuries",
|
|
113
|
+
]) {
|
|
114
|
+
this.db.prepare(`DELETE FROM ${table}`).run();
|
|
115
|
+
}
|
|
116
|
+
this.insertCache(cache);
|
|
117
|
+
this.db.exec("COMMIT");
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
this.db.exec("ROLLBACK");
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Upsert entities from a CampusCache slice (merge semantics). */
|
|
125
|
+
upsertCache(cache) {
|
|
126
|
+
this.db.exec("BEGIN");
|
|
127
|
+
try {
|
|
128
|
+
this.insertCache(cache);
|
|
129
|
+
this.db.exec("COMMIT");
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
this.db.exec("ROLLBACK");
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
loadCache() {
|
|
137
|
+
const competitions = this.db
|
|
138
|
+
.prepare("SELECT * FROM competitions")
|
|
139
|
+
.all()
|
|
140
|
+
.map((row) => ({
|
|
141
|
+
id: String(row.id),
|
|
142
|
+
name: String(row.name),
|
|
143
|
+
country: row.country == null ? undefined : String(row.country),
|
|
144
|
+
gender: "female",
|
|
145
|
+
sources: parseJson(String(row.sources_json)),
|
|
146
|
+
}));
|
|
147
|
+
const seasons = this.db
|
|
148
|
+
.prepare("SELECT * FROM seasons")
|
|
149
|
+
.all()
|
|
150
|
+
.map((row) => ({
|
|
151
|
+
id: String(row.id),
|
|
152
|
+
name: String(row.name),
|
|
153
|
+
competitionId: String(row.competition_id),
|
|
154
|
+
sources: parseJson(String(row.sources_json)),
|
|
155
|
+
}));
|
|
156
|
+
const teams = this.db
|
|
157
|
+
.prepare("SELECT * FROM teams")
|
|
158
|
+
.all()
|
|
159
|
+
.map((row) => ({
|
|
160
|
+
id: String(row.id),
|
|
161
|
+
name: String(row.name),
|
|
162
|
+
country: row.country == null ? undefined : String(row.country),
|
|
163
|
+
sources: parseJson(String(row.sources_json)),
|
|
164
|
+
}));
|
|
165
|
+
const matches = this.db
|
|
166
|
+
.prepare("SELECT * FROM matches")
|
|
167
|
+
.all()
|
|
168
|
+
.map((row) => ({
|
|
169
|
+
id: String(row.id),
|
|
170
|
+
competitionId: String(row.competition_id),
|
|
171
|
+
seasonId: String(row.season_id),
|
|
172
|
+
date: row.date == null ? undefined : String(row.date),
|
|
173
|
+
homeTeamId: String(row.home_team_id),
|
|
174
|
+
awayTeamId: String(row.away_team_id),
|
|
175
|
+
homeScore: row.home_score == null ? null : Number(row.home_score),
|
|
176
|
+
awayScore: row.away_score == null ? null : Number(row.away_score),
|
|
177
|
+
sources: parseJson(String(row.sources_json)),
|
|
178
|
+
}));
|
|
179
|
+
const identities = this.db
|
|
180
|
+
.prepare("SELECT * FROM identities")
|
|
181
|
+
.all()
|
|
182
|
+
.map((row) => ({
|
|
183
|
+
id: String(row.id),
|
|
184
|
+
kind: String(row.kind),
|
|
185
|
+
name: String(row.name),
|
|
186
|
+
aliases: parseJson(String(row.aliases_json)),
|
|
187
|
+
sources: parseJson(String(row.sources_json)),
|
|
188
|
+
confidence: String(row.confidence),
|
|
189
|
+
status: String(row.status),
|
|
190
|
+
competitionHint: row.competition_hint == null
|
|
191
|
+
? undefined
|
|
192
|
+
: String(row.competition_hint),
|
|
193
|
+
}));
|
|
194
|
+
const players = this.db
|
|
195
|
+
.prepare("SELECT * FROM players")
|
|
196
|
+
.all()
|
|
197
|
+
.map((row) => ({
|
|
198
|
+
id: String(row.id),
|
|
199
|
+
name: String(row.name),
|
|
200
|
+
nickname: row.nickname == null ? undefined : String(row.nickname),
|
|
201
|
+
country: row.country == null ? undefined : String(row.country),
|
|
202
|
+
sources: parseJson(String(row.sources_json)),
|
|
203
|
+
}));
|
|
204
|
+
const playerMatchStats = this.db
|
|
205
|
+
.prepare("SELECT * FROM player_match_stats")
|
|
206
|
+
.all()
|
|
207
|
+
.map((row) => ({
|
|
208
|
+
id: String(row.id),
|
|
209
|
+
matchId: String(row.match_id),
|
|
210
|
+
playerId: String(row.player_id),
|
|
211
|
+
teamId: String(row.team_id),
|
|
212
|
+
minutes: row.minutes == null ? null : Number(row.minutes),
|
|
213
|
+
goals: Number(row.goals),
|
|
214
|
+
assists: Number(row.assists),
|
|
215
|
+
yellowCards: Number(row.yellow_cards),
|
|
216
|
+
redCards: Number(row.red_cards),
|
|
217
|
+
sources: parseJson(String(row.sources_json)),
|
|
218
|
+
}));
|
|
219
|
+
const lineups = this.db
|
|
220
|
+
.prepare("SELECT * FROM lineups")
|
|
221
|
+
.all()
|
|
222
|
+
.map((row) => ({
|
|
223
|
+
id: String(row.id),
|
|
224
|
+
matchId: String(row.match_id),
|
|
225
|
+
teamId: String(row.team_id),
|
|
226
|
+
playerId: String(row.player_id),
|
|
227
|
+
started: Boolean(row.started),
|
|
228
|
+
jerseyNumber: row.jersey_number == null ? null : Number(row.jersey_number),
|
|
229
|
+
sources: parseJson(String(row.sources_json)),
|
|
230
|
+
}));
|
|
231
|
+
const injuries = this.db
|
|
232
|
+
.prepare("SELECT * FROM injuries")
|
|
233
|
+
.all()
|
|
234
|
+
.map((row) => ({
|
|
235
|
+
id: String(row.id),
|
|
236
|
+
playerId: String(row.player_id),
|
|
237
|
+
teamId: row.team_id == null ? undefined : String(row.team_id),
|
|
238
|
+
status: String(row.status),
|
|
239
|
+
description: row.description == null ? undefined : String(row.description),
|
|
240
|
+
fromDate: row.from_date == null ? undefined : String(row.from_date),
|
|
241
|
+
toDate: row.to_date == null ? undefined : String(row.to_date),
|
|
242
|
+
sources: parseJson(String(row.sources_json)),
|
|
243
|
+
}));
|
|
244
|
+
return {
|
|
245
|
+
competitions,
|
|
246
|
+
seasons,
|
|
247
|
+
teams,
|
|
248
|
+
matches,
|
|
249
|
+
identities,
|
|
250
|
+
players,
|
|
251
|
+
playerMatchStats,
|
|
252
|
+
lineups,
|
|
253
|
+
injuries,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
insertCache(cache) {
|
|
257
|
+
const upsertCompetition = this.db.prepare(`INSERT INTO competitions (id, name, country, gender, sources_json)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?)
|
|
259
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
260
|
+
name=excluded.name, country=excluded.country, gender=excluded.gender,
|
|
261
|
+
sources_json=excluded.sources_json`);
|
|
262
|
+
for (const c of cache.competitions) {
|
|
263
|
+
upsertCompetition.run(c.id, c.name, c.country ?? null, c.gender, j(c.sources));
|
|
264
|
+
}
|
|
265
|
+
const upsertSeason = this.db.prepare(`INSERT INTO seasons (id, name, competition_id, sources_json)
|
|
266
|
+
VALUES (?, ?, ?, ?)
|
|
267
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
268
|
+
name=excluded.name, competition_id=excluded.competition_id,
|
|
269
|
+
sources_json=excluded.sources_json`);
|
|
270
|
+
for (const s of cache.seasons) {
|
|
271
|
+
upsertSeason.run(s.id, s.name, s.competitionId, j(s.sources));
|
|
272
|
+
}
|
|
273
|
+
const upsertTeam = this.db.prepare(`INSERT INTO teams (id, name, country, sources_json)
|
|
274
|
+
VALUES (?, ?, ?, ?)
|
|
275
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
276
|
+
name=excluded.name, country=excluded.country, sources_json=excluded.sources_json`);
|
|
277
|
+
for (const t of cache.teams) {
|
|
278
|
+
upsertTeam.run(t.id, t.name, t.country ?? null, j(t.sources));
|
|
279
|
+
}
|
|
280
|
+
const upsertMatch = this.db.prepare(`INSERT INTO matches (
|
|
281
|
+
id, competition_id, season_id, date, home_team_id, away_team_id,
|
|
282
|
+
home_score, away_score, sources_json
|
|
283
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
284
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
285
|
+
competition_id=excluded.competition_id, season_id=excluded.season_id,
|
|
286
|
+
date=excluded.date, home_team_id=excluded.home_team_id,
|
|
287
|
+
away_team_id=excluded.away_team_id, home_score=excluded.home_score,
|
|
288
|
+
away_score=excluded.away_score, sources_json=excluded.sources_json`);
|
|
289
|
+
for (const m of cache.matches) {
|
|
290
|
+
upsertMatch.run(m.id, m.competitionId, m.seasonId, m.date ?? null, m.homeTeamId, m.awayTeamId, m.homeScore ?? null, m.awayScore ?? null, j(m.sources));
|
|
291
|
+
}
|
|
292
|
+
const upsertIdentity = this.db.prepare(`INSERT INTO identities (
|
|
293
|
+
id, kind, name, aliases_json, sources_json, confidence, status, competition_hint
|
|
294
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
295
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
296
|
+
kind=excluded.kind, name=excluded.name, aliases_json=excluded.aliases_json,
|
|
297
|
+
sources_json=excluded.sources_json, confidence=excluded.confidence,
|
|
298
|
+
status=excluded.status, competition_hint=excluded.competition_hint`);
|
|
299
|
+
for (const i of cache.identities) {
|
|
300
|
+
upsertIdentity.run(i.id, i.kind, i.name, j(i.aliases), j(i.sources), i.confidence, i.status, i.competitionHint ?? null);
|
|
301
|
+
}
|
|
302
|
+
const upsertPlayer = this.db.prepare(`INSERT INTO players (id, name, nickname, country, sources_json)
|
|
303
|
+
VALUES (?, ?, ?, ?, ?)
|
|
304
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
305
|
+
name=excluded.name, nickname=excluded.nickname, country=excluded.country,
|
|
306
|
+
sources_json=excluded.sources_json`);
|
|
307
|
+
for (const p of cache.players) {
|
|
308
|
+
upsertPlayer.run(p.id, p.name, p.nickname ?? null, p.country ?? null, j(p.sources));
|
|
309
|
+
}
|
|
310
|
+
const upsertStats = this.db.prepare(`INSERT INTO player_match_stats (
|
|
311
|
+
id, match_id, player_id, team_id, minutes, goals, assists,
|
|
312
|
+
yellow_cards, red_cards, sources_json
|
|
313
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
314
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
315
|
+
match_id=excluded.match_id, player_id=excluded.player_id,
|
|
316
|
+
team_id=excluded.team_id, minutes=excluded.minutes, goals=excluded.goals,
|
|
317
|
+
assists=excluded.assists, yellow_cards=excluded.yellow_cards,
|
|
318
|
+
red_cards=excluded.red_cards, sources_json=excluded.sources_json`);
|
|
319
|
+
for (const s of cache.playerMatchStats) {
|
|
320
|
+
upsertStats.run(s.id, s.matchId, s.playerId, s.teamId, s.minutes ?? null, s.goals, s.assists, s.yellowCards, s.redCards, j(s.sources));
|
|
321
|
+
}
|
|
322
|
+
const upsertLineup = this.db.prepare(`INSERT INTO lineups (
|
|
323
|
+
id, match_id, team_id, player_id, started, jersey_number, sources_json
|
|
324
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
325
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
326
|
+
match_id=excluded.match_id, team_id=excluded.team_id,
|
|
327
|
+
player_id=excluded.player_id, started=excluded.started,
|
|
328
|
+
jersey_number=excluded.jersey_number, sources_json=excluded.sources_json`);
|
|
329
|
+
for (const l of cache.lineups) {
|
|
330
|
+
upsertLineup.run(l.id, l.matchId, l.teamId, l.playerId, l.started ? 1 : 0, l.jerseyNumber ?? null, j(l.sources));
|
|
331
|
+
}
|
|
332
|
+
const upsertInjury = this.db.prepare(`INSERT INTO injuries (
|
|
333
|
+
id, player_id, team_id, status, description, from_date, to_date, sources_json
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
335
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
336
|
+
player_id=excluded.player_id, team_id=excluded.team_id,
|
|
337
|
+
status=excluded.status, description=excluded.description,
|
|
338
|
+
from_date=excluded.from_date, to_date=excluded.to_date,
|
|
339
|
+
sources_json=excluded.sources_json`);
|
|
340
|
+
for (const i of cache.injuries) {
|
|
341
|
+
upsertInjury.run(i.id, i.playerId, i.teamId ?? null, i.status, i.description ?? null, i.fromDate ?? null, i.toDate ?? null, j(i.sources));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|