untappd-mcp 1.2.0 → 1.3.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +45 -0
- package/SKILL.md +29 -0
- package/dist/bundle.js +613 -47
- package/dist/cache/db.js +293 -0
- package/dist/cache/sync.js +202 -0
- package/dist/index.js +5 -0
- package/dist/tools/cache.js +174 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/cache/db.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
6
|
+
function asDict(v) {
|
|
7
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
8
|
+
}
|
|
9
|
+
function num(v) {
|
|
10
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
11
|
+
}
|
|
12
|
+
function str(v) {
|
|
13
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Normalise a raw `/user/checkins` item to a {@link CheckinRow}. Reads only the
|
|
17
|
+
* documented list fields (the same ones `compactCheckin` uses) and derives
|
|
18
|
+
* nothing it can't. `created_at` is normalised to ISO-8601 UTC so it sorts
|
|
19
|
+
* lexicographically = chronologically and date-range filters are plain string
|
|
20
|
+
* comparisons; the raw RFC-2822 string is kept if it can't be parsed.
|
|
21
|
+
*/
|
|
22
|
+
export function mapCheckinRow(username, item) {
|
|
23
|
+
const c = asDict(item);
|
|
24
|
+
const checkin_id = num(c.checkin_id);
|
|
25
|
+
if (checkin_id === null)
|
|
26
|
+
return null; // can't key it — skip rather than corrupt the table
|
|
27
|
+
const beer = asDict(c.beer);
|
|
28
|
+
const brewery = asDict(c.brewery);
|
|
29
|
+
const venue = asDict(c.venue); // Untappd sends `venue: []` when none — asDict → {}
|
|
30
|
+
return {
|
|
31
|
+
checkin_id,
|
|
32
|
+
username,
|
|
33
|
+
bid: num(beer.bid),
|
|
34
|
+
beer_name: str(beer.beer_name),
|
|
35
|
+
brewery_id: num(brewery.brewery_id),
|
|
36
|
+
brewery_name: str(brewery.brewery_name),
|
|
37
|
+
beer_style: str(beer.beer_style),
|
|
38
|
+
abv: num(beer.beer_abv),
|
|
39
|
+
rating: num(c.rating_score),
|
|
40
|
+
comment: str(c.checkin_comment),
|
|
41
|
+
venue_id: num(venue.venue_id),
|
|
42
|
+
venue_name: str(venue.venue_name),
|
|
43
|
+
created_at: normaliseDate(c.created_at),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function normaliseDate(v) {
|
|
47
|
+
const s = str(v);
|
|
48
|
+
if (!s)
|
|
49
|
+
return null;
|
|
50
|
+
const t = Date.parse(s);
|
|
51
|
+
return Number.isNaN(t) ? s : new Date(t).toISOString();
|
|
52
|
+
}
|
|
53
|
+
const SCHEMA = `
|
|
54
|
+
CREATE TABLE IF NOT EXISTS checkins (
|
|
55
|
+
checkin_id INTEGER PRIMARY KEY,
|
|
56
|
+
username TEXT NOT NULL,
|
|
57
|
+
bid INTEGER,
|
|
58
|
+
beer_name TEXT,
|
|
59
|
+
brewery_id INTEGER,
|
|
60
|
+
brewery_name TEXT,
|
|
61
|
+
beer_style TEXT,
|
|
62
|
+
abv REAL,
|
|
63
|
+
rating REAL,
|
|
64
|
+
comment TEXT,
|
|
65
|
+
venue_id INTEGER,
|
|
66
|
+
venue_name TEXT,
|
|
67
|
+
created_at TEXT
|
|
68
|
+
);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_checkins_user_bid ON checkins(username, bid);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_checkins_user_brewery ON checkins(username, brewery_id);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS sync_state (
|
|
73
|
+
username TEXT PRIMARY KEY,
|
|
74
|
+
oldest_max_id INTEGER,
|
|
75
|
+
newest_checkin_id INTEGER,
|
|
76
|
+
catchup_max_id INTEGER,
|
|
77
|
+
last_synced_at TEXT,
|
|
78
|
+
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
total_checkins INTEGER
|
|
80
|
+
);
|
|
81
|
+
`;
|
|
82
|
+
/**
|
|
83
|
+
* Wraps a single SQLite database file. Usernames are compared
|
|
84
|
+
* case-insensitively via a lowercased key so `Mer1331` and `mer1331` share one
|
|
85
|
+
* cache; the original casing the caller passed is preserved in stored rows.
|
|
86
|
+
*/
|
|
87
|
+
export class CheckinCache {
|
|
88
|
+
db;
|
|
89
|
+
constructor(db) {
|
|
90
|
+
this.db = db;
|
|
91
|
+
this.db.exec(SCHEMA);
|
|
92
|
+
this.migrate();
|
|
93
|
+
}
|
|
94
|
+
// Forward-compat for a sync_state table created before a column existed. Each
|
|
95
|
+
// ADD COLUMN throws harmlessly if the column is already present.
|
|
96
|
+
migrate() {
|
|
97
|
+
for (const col of ['catchup_max_id INTEGER']) {
|
|
98
|
+
try {
|
|
99
|
+
this.db.exec(`ALTER TABLE sync_state ADD COLUMN ${col}`);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* column already exists */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
|
|
107
|
+
static open(path) {
|
|
108
|
+
if (path !== ':memory:')
|
|
109
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
110
|
+
return new CheckinCache(new DatabaseSync(path));
|
|
111
|
+
}
|
|
112
|
+
close() {
|
|
113
|
+
this.db.close();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Upsert a batch of rows (dedupe on checkin_id) inside one transaction and
|
|
117
|
+
* return the NET number of newly-inserted rows (updates to existing rows count
|
|
118
|
+
* as 0), which is what "rows added" reports.
|
|
119
|
+
*/
|
|
120
|
+
upsertCheckins(username, rows) {
|
|
121
|
+
if (rows.length === 0)
|
|
122
|
+
return 0;
|
|
123
|
+
const key = username.toLowerCase();
|
|
124
|
+
const before = this.cachedCount(username);
|
|
125
|
+
const stmt = this.db.prepare(`
|
|
126
|
+
INSERT INTO checkins
|
|
127
|
+
(checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
ON CONFLICT(checkin_id) DO UPDATE SET
|
|
130
|
+
username=excluded.username, bid=excluded.bid, beer_name=excluded.beer_name,
|
|
131
|
+
brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
|
|
132
|
+
abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
|
|
133
|
+
venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at
|
|
134
|
+
`);
|
|
135
|
+
this.db.exec('BEGIN');
|
|
136
|
+
try {
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
stmt.run(r.checkin_id, key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.beer_style, r.abv, r.rating, r.comment, r.venue_id, r.venue_name, r.created_at);
|
|
139
|
+
}
|
|
140
|
+
this.db.exec('COMMIT');
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
this.db.exec('ROLLBACK');
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
return this.cachedCount(username) - before;
|
|
147
|
+
}
|
|
148
|
+
/** Total cached check-ins for a user (= distinct check-ins, since PK is checkin_id). */
|
|
149
|
+
cachedCount(username) {
|
|
150
|
+
const row = this.db
|
|
151
|
+
.prepare('SELECT COUNT(*) AS n FROM checkins WHERE username = ?')
|
|
152
|
+
.get(username.toLowerCase());
|
|
153
|
+
return row.n;
|
|
154
|
+
}
|
|
155
|
+
/** Distinct beers (unique bids) cached for a user. */
|
|
156
|
+
distinctBeerCount(username) {
|
|
157
|
+
const row = this.db
|
|
158
|
+
.prepare('SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL')
|
|
159
|
+
.get(username.toLowerCase());
|
|
160
|
+
return row.n;
|
|
161
|
+
}
|
|
162
|
+
/** Highest cached checkin_id for a user (the true newest once contiguous), or null. */
|
|
163
|
+
newestCachedId(username) {
|
|
164
|
+
const row = this.db
|
|
165
|
+
.prepare('SELECT MAX(checkin_id) AS m FROM checkins WHERE username = ?')
|
|
166
|
+
.get(username.toLowerCase());
|
|
167
|
+
return row.m;
|
|
168
|
+
}
|
|
169
|
+
getState(username) {
|
|
170
|
+
const row = this.db.prepare('SELECT * FROM sync_state WHERE username = ?').get(username.toLowerCase());
|
|
171
|
+
if (!row)
|
|
172
|
+
return undefined;
|
|
173
|
+
return { ...row, username: row.username, backfill_complete: row.backfill_complete === 1 };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Insert-or-merge sync state. Only keys PRESENT in `patch` are written, so an
|
|
177
|
+
* explicit `null` (e.g. clearing `oldest_max_id` when backfill completes)
|
|
178
|
+
* overrides the stored value — a `??` merge could not do that.
|
|
179
|
+
*/
|
|
180
|
+
setState(username, patch) {
|
|
181
|
+
const key = username.toLowerCase();
|
|
182
|
+
const cur = this.getState(key);
|
|
183
|
+
const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
|
|
184
|
+
const next = {
|
|
185
|
+
username: key,
|
|
186
|
+
oldest_max_id: pick('oldest_max_id', null),
|
|
187
|
+
newest_checkin_id: pick('newest_checkin_id', null),
|
|
188
|
+
catchup_max_id: pick('catchup_max_id', null),
|
|
189
|
+
last_synced_at: pick('last_synced_at', null),
|
|
190
|
+
backfill_complete: pick('backfill_complete', false),
|
|
191
|
+
total_checkins: pick('total_checkins', null),
|
|
192
|
+
};
|
|
193
|
+
this.db
|
|
194
|
+
.prepare(`INSERT INTO sync_state (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
196
|
+
ON CONFLICT(username) DO UPDATE SET
|
|
197
|
+
oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
|
|
198
|
+
catchup_max_id=excluded.catchup_max_id,
|
|
199
|
+
last_synced_at=excluded.last_synced_at, backfill_complete=excluded.backfill_complete,
|
|
200
|
+
total_checkins=excluded.total_checkins`)
|
|
201
|
+
.run(key, next.oldest_max_id, next.newest_checkin_id, next.catchup_max_id, next.last_synced_at, next.backfill_complete ? 1 : 0, next.total_checkins);
|
|
202
|
+
}
|
|
203
|
+
/** Exact bid match or case-insensitive substring match on beer name. */
|
|
204
|
+
hasHad(username, opts) {
|
|
205
|
+
const where = ['username = ?'];
|
|
206
|
+
const params = [username.toLowerCase()];
|
|
207
|
+
if (opts.bid !== undefined) {
|
|
208
|
+
where.push('bid = ?');
|
|
209
|
+
params.push(opts.bid);
|
|
210
|
+
}
|
|
211
|
+
if (opts.beerName !== undefined) {
|
|
212
|
+
where.push('beer_name LIKE ? COLLATE NOCASE');
|
|
213
|
+
params.push(`%${escapeLike(opts.beerName)}%`);
|
|
214
|
+
}
|
|
215
|
+
const clause = where.join(' AND ');
|
|
216
|
+
const agg = this.db
|
|
217
|
+
.prepare(`SELECT COUNT(*) AS n, MAX(rating) AS best FROM checkins WHERE ${clause}`)
|
|
218
|
+
.get(...params);
|
|
219
|
+
// Most-recent first: checkin_id is monotonic in time, a reliable ordering
|
|
220
|
+
// even though created_at is a formatted string.
|
|
221
|
+
const matches = this.db
|
|
222
|
+
.prepare(`SELECT checkin_id, bid, beer_name, rating, venue_name, created_at
|
|
223
|
+
FROM checkins WHERE ${clause} ORDER BY checkin_id DESC LIMIT 25`)
|
|
224
|
+
.all(...params);
|
|
225
|
+
return {
|
|
226
|
+
had: agg.n > 0,
|
|
227
|
+
count: agg.n,
|
|
228
|
+
last_date: matches[0]?.created_at ?? null,
|
|
229
|
+
best_rating: agg.best,
|
|
230
|
+
matches,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
query(username, filters) {
|
|
234
|
+
const where = ['username = ?'];
|
|
235
|
+
const params = [username.toLowerCase()];
|
|
236
|
+
if (filters.brewery_id !== undefined) {
|
|
237
|
+
where.push('brewery_id = ?');
|
|
238
|
+
params.push(filters.brewery_id);
|
|
239
|
+
}
|
|
240
|
+
if (filters.brewery !== undefined) {
|
|
241
|
+
where.push('brewery_name LIKE ? COLLATE NOCASE');
|
|
242
|
+
params.push(`%${escapeLike(filters.brewery)}%`);
|
|
243
|
+
}
|
|
244
|
+
if (filters.style !== undefined) {
|
|
245
|
+
where.push('beer_style LIKE ? COLLATE NOCASE');
|
|
246
|
+
params.push(`%${escapeLike(filters.style)}%`);
|
|
247
|
+
}
|
|
248
|
+
if (filters.min_rating !== undefined) {
|
|
249
|
+
where.push('rating >= ?');
|
|
250
|
+
params.push(filters.min_rating);
|
|
251
|
+
}
|
|
252
|
+
if (filters.venue !== undefined) {
|
|
253
|
+
where.push('venue_name LIKE ? COLLATE NOCASE');
|
|
254
|
+
params.push(`%${escapeLike(filters.venue)}%`);
|
|
255
|
+
}
|
|
256
|
+
if (filters.venue_id !== undefined) {
|
|
257
|
+
where.push('venue_id = ?');
|
|
258
|
+
params.push(filters.venue_id);
|
|
259
|
+
}
|
|
260
|
+
// created_at is ISO-8601, so a YYYY-MM-DD bound compares correctly on its date prefix.
|
|
261
|
+
if (filters.date_from !== undefined) {
|
|
262
|
+
where.push('substr(created_at, 1, 10) >= ?');
|
|
263
|
+
params.push(filters.date_from);
|
|
264
|
+
}
|
|
265
|
+
if (filters.date_to !== undefined) {
|
|
266
|
+
where.push('substr(created_at, 1, 10) <= ?');
|
|
267
|
+
params.push(filters.date_to);
|
|
268
|
+
}
|
|
269
|
+
const order = filters.sort === 'oldest'
|
|
270
|
+
? 'checkin_id ASC'
|
|
271
|
+
: filters.sort === 'highest_rated'
|
|
272
|
+
? 'rating DESC, checkin_id DESC'
|
|
273
|
+
: filters.sort === 'lowest_rated'
|
|
274
|
+
? 'rating ASC, checkin_id DESC'
|
|
275
|
+
: 'checkin_id DESC';
|
|
276
|
+
const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
|
|
277
|
+
const rows = this.db
|
|
278
|
+
.prepare(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`)
|
|
279
|
+
.all(...params, limit);
|
|
280
|
+
return rows;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Escape LIKE wildcards in user input so a literal % or _ isn't treated as a
|
|
284
|
+
// wildcard. Our LIKE patterns don't set an ESCAPE clause, so we only need to
|
|
285
|
+
// avoid a query that matches unexpectedly; backslash-escaping the specials and
|
|
286
|
+
// pairing with the default is not portable, so we simply strip them.
|
|
287
|
+
function escapeLike(s) {
|
|
288
|
+
return s.replace(/[%_]/g, ' ');
|
|
289
|
+
}
|
|
290
|
+
/** Default on-disk cache path: `$UNTAPPD_CACHE_DB` or `~/.untappd-mcp/checkins.db`. */
|
|
291
|
+
export function defaultCachePath() {
|
|
292
|
+
return readEnvVar('UNTAPPD_CACHE_DB') ?? join(homedir(), '.untappd-mcp', 'checkins.db');
|
|
293
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
|
+
import { mapCheckinRow } from './db.js';
|
|
3
|
+
const PAGE_LIMIT = 50; // Untappd's max page size for /user/checkins.
|
|
4
|
+
/** One `/user/checkins` page fetch, tolerant of the fat/undocumented shape. */
|
|
5
|
+
async function fetchPage(client, encodedUser, maxId) {
|
|
6
|
+
const data = await client.get(`/user/checkins/${encodedUser}`, { limit: PAGE_LIMIT, max_id: maxId });
|
|
7
|
+
const box = data?.checkins ?? {};
|
|
8
|
+
const items = Array.isArray(box.items) ? box.items : [];
|
|
9
|
+
const raw = box.pagination?.max_id;
|
|
10
|
+
const nextMaxId = typeof raw === 'number' && raw > 0 ? raw : null;
|
|
11
|
+
return { items, nextMaxId };
|
|
12
|
+
}
|
|
13
|
+
function rowsOf(username, items) {
|
|
14
|
+
const rows = [];
|
|
15
|
+
for (const it of items) {
|
|
16
|
+
const r = mapCheckinRow(username, it);
|
|
17
|
+
if (r)
|
|
18
|
+
rows.push(r);
|
|
19
|
+
}
|
|
20
|
+
return rows;
|
|
21
|
+
}
|
|
22
|
+
/** Best-effort stats.total_checkins for the % estimate; null if it can't be read. */
|
|
23
|
+
async function fetchTotalCheckins(client, encodedUser) {
|
|
24
|
+
try {
|
|
25
|
+
const info = await client.get(`/user/info/${encodedUser}`);
|
|
26
|
+
const t = info?.user?.stats?.total_checkins;
|
|
27
|
+
return typeof t === 'number' ? t : null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Sync a user's Untappd check-ins into the local cache.
|
|
35
|
+
*
|
|
36
|
+
* - **Incremental**: on a repeat sync, page from the top and stop as soon as a
|
|
37
|
+
* check-in at or below the stored `newest_checkin_id` appears (already cached).
|
|
38
|
+
* - **Backfill**: while `backfill_complete` is false, resume paging backwards
|
|
39
|
+
* from the stored `oldest_max_id`, up to `maxPages` pages per invocation so a
|
|
40
|
+
* single run stays well under the rate limit. State is persisted after EVERY
|
|
41
|
+
* page, so an interrupted run (rate limit, crash) resumes exactly where it
|
|
42
|
+
* stopped and never loses fetched data.
|
|
43
|
+
*
|
|
44
|
+
* `maxPages` is the TOTAL page budget for the invocation (incremental +
|
|
45
|
+
* backfill), so the call can never fetch more than `maxPages` pages.
|
|
46
|
+
*/
|
|
47
|
+
export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
|
|
48
|
+
const encodedUser = encodeURIComponent(rawUsername);
|
|
49
|
+
const prior = cache.getState(rawUsername);
|
|
50
|
+
const priorNewest = prior?.newest_checkin_id ?? null;
|
|
51
|
+
let pages = 0;
|
|
52
|
+
let added = 0;
|
|
53
|
+
let newest = priorNewest;
|
|
54
|
+
let backfillComplete = prior?.backfill_complete ?? false;
|
|
55
|
+
let oldestMaxId = prior?.oldest_max_id ?? null;
|
|
56
|
+
// Resume cursor for an unfinished top catch-up. Non-null → a prior run advanced
|
|
57
|
+
// partway through a burst of new check-ins but ran out of page budget before
|
|
58
|
+
// reconnecting to the cached block; this run resumes from here.
|
|
59
|
+
let catchupMaxId = prior?.catchup_max_id ?? null;
|
|
60
|
+
let catchupInProgress = catchupMaxId !== null;
|
|
61
|
+
const now = () => new Date().toISOString();
|
|
62
|
+
// Wrap the very first fetch so a private/non-friend account produces a clear,
|
|
63
|
+
// actionable error instead of a raw upstream failure. Rate limits are
|
|
64
|
+
// preserved as-is (they're meaningful and any prior progress is saved).
|
|
65
|
+
const firstFetch = async (maxId) => {
|
|
66
|
+
try {
|
|
67
|
+
return await fetchPage(client, encodedUser, maxId);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e instanceof RateLimitError)
|
|
71
|
+
throw e;
|
|
72
|
+
throw createHelpfulError(`Could not fetch check-ins for "${rawUsername}": ${messageOf(e)}`, {
|
|
73
|
+
hint: "Untappd only returns another user's check-ins if their account is public or they're your friend. If the account is private, add them as a friend (untappd_add_friend) first, or sync only your own account.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
// ── Phase 1: incremental catch-up of the new-top region (resumable) ──
|
|
78
|
+
// Only meaningful once we have a cached block (priorNewest set). Pages from the
|
|
79
|
+
// top (or a resumed cursor) DOWN until it reconnects to the cached block —
|
|
80
|
+
// i.e. a page whose oldest id <= priorNewest. Crucially, `newest_checkin_id`
|
|
81
|
+
// is NOT advanced until that reconnection happens: if the run exhausts its page
|
|
82
|
+
// budget first, the boundary stays put and a resume cursor is saved, so a burst
|
|
83
|
+
// of more than maxPages*50 new check-ins can never strand a permanent gap.
|
|
84
|
+
if (priorNewest !== null) {
|
|
85
|
+
let maxId = catchupMaxId ?? undefined;
|
|
86
|
+
let caughtUp = false;
|
|
87
|
+
for (; pages < maxPages;) {
|
|
88
|
+
const page = pages === 0 ? await firstFetch(maxId) : await fetchPage(client, encodedUser, maxId);
|
|
89
|
+
pages++;
|
|
90
|
+
if (page.items.length === 0) {
|
|
91
|
+
caughtUp = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
const rows = rowsOf(rawUsername, page.items);
|
|
95
|
+
added += cache.upsertCheckins(rawUsername, rows);
|
|
96
|
+
const oldestOnPage = rows[rows.length - 1].checkin_id;
|
|
97
|
+
if (oldestOnPage <= priorNewest) {
|
|
98
|
+
caughtUp = true; // reconnected to the cached block
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
if (page.nextMaxId === null) {
|
|
102
|
+
// Paged all the way to the true bottom via the top — the whole history
|
|
103
|
+
// is now contiguous, so the backfill is complete too.
|
|
104
|
+
caughtUp = true;
|
|
105
|
+
backfillComplete = true;
|
|
106
|
+
oldestMaxId = null;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
catchupMaxId = page.nextMaxId;
|
|
110
|
+
// Persist the resume cursor after EVERY page; the boundary stays put.
|
|
111
|
+
cache.setState(rawUsername, { catchup_max_id: catchupMaxId, last_synced_at: now() });
|
|
112
|
+
maxId = page.nextMaxId;
|
|
113
|
+
}
|
|
114
|
+
if (caughtUp) {
|
|
115
|
+
// Contiguous from the true newest down to the old block: advance the
|
|
116
|
+
// boundary and clear the catch-up cursor.
|
|
117
|
+
newest = cache.newestCachedId(rawUsername);
|
|
118
|
+
catchupMaxId = null;
|
|
119
|
+
catchupInProgress = false;
|
|
120
|
+
cache.setState(rawUsername, {
|
|
121
|
+
newest_checkin_id: newest,
|
|
122
|
+
catchup_max_id: null,
|
|
123
|
+
backfill_complete: backfillComplete,
|
|
124
|
+
oldest_max_id: oldestMaxId,
|
|
125
|
+
last_synced_at: now(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
catchupInProgress = true; // budget spent mid-catch-up; next run resumes
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── Phase 2: backfill older history (resumable) ──
|
|
133
|
+
// Skipped while a catch-up is still in progress (that already spent the budget).
|
|
134
|
+
if (!backfillComplete && !catchupInProgress) {
|
|
135
|
+
// First-ever sync starts from the top (undefined); a resumed backfill picks
|
|
136
|
+
// up from the stored cursor.
|
|
137
|
+
let maxId = oldestMaxId ?? undefined;
|
|
138
|
+
for (; pages < maxPages;) {
|
|
139
|
+
const first = pages === 0; // true only on a first-ever sync (phase 1 was skipped)
|
|
140
|
+
const page = first ? await firstFetch(maxId) : await fetchPage(client, encodedUser, maxId);
|
|
141
|
+
pages++;
|
|
142
|
+
if (page.items.length === 0) {
|
|
143
|
+
backfillComplete = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
const rows = rowsOf(rawUsername, page.items);
|
|
147
|
+
added += cache.upsertCheckins(rawUsername, rows);
|
|
148
|
+
if (newest === null)
|
|
149
|
+
newest = rows[0].checkin_id; // first-ever sync sets the boundary
|
|
150
|
+
oldestMaxId = page.nextMaxId;
|
|
151
|
+
if (page.nextMaxId === null)
|
|
152
|
+
backfillComplete = true;
|
|
153
|
+
// Persist progress after EVERY page so an interruption never loses work.
|
|
154
|
+
cache.setState(rawUsername, {
|
|
155
|
+
newest_checkin_id: newest,
|
|
156
|
+
oldest_max_id: oldestMaxId,
|
|
157
|
+
backfill_complete: backfillComplete,
|
|
158
|
+
last_synced_at: now(),
|
|
159
|
+
});
|
|
160
|
+
if (backfillComplete)
|
|
161
|
+
break;
|
|
162
|
+
maxId = page.nextMaxId ?? undefined;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const total = await fetchTotalCheckins(client, encodedUser);
|
|
166
|
+
const cached = cache.cachedCount(rawUsername);
|
|
167
|
+
cache.setState(rawUsername, {
|
|
168
|
+
newest_checkin_id: newest,
|
|
169
|
+
oldest_max_id: oldestMaxId,
|
|
170
|
+
catchup_max_id: catchupMaxId,
|
|
171
|
+
backfill_complete: backfillComplete,
|
|
172
|
+
total_checkins: total,
|
|
173
|
+
last_synced_at: now(),
|
|
174
|
+
});
|
|
175
|
+
const backfillPercent = total && total > 0
|
|
176
|
+
? Math.min(100, Math.round((cached / total) * 100))
|
|
177
|
+
: backfillComplete && !catchupInProgress
|
|
178
|
+
? 100
|
|
179
|
+
: null;
|
|
180
|
+
// Another run is needed while EITHER frontier is unfinished: the bottom
|
|
181
|
+
// backfill, or an in-progress top catch-up of a large burst of new check-ins.
|
|
182
|
+
const anotherRunNeeded = !backfillComplete || catchupInProgress;
|
|
183
|
+
const note = catchupInProgress
|
|
184
|
+
? 'More new check-ins remain than fit in this run — run untappd_sync_checkins again to finish catching up the newest check-ins.'
|
|
185
|
+
: backfillComplete
|
|
186
|
+
? 'Full history is cached. Re-run occasionally to pick up new check-ins.'
|
|
187
|
+
: `Backfill incomplete — run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
|
|
188
|
+
return {
|
|
189
|
+
username: rawUsername,
|
|
190
|
+
rows_added: added,
|
|
191
|
+
pages_fetched: pages,
|
|
192
|
+
cached_checkins: cached,
|
|
193
|
+
distinct_beers: cache.distinctBeerCount(rawUsername),
|
|
194
|
+
total_checkins: total,
|
|
195
|
+
backfill_percent: backfillPercent,
|
|
196
|
+
backfill_complete: backfillComplete,
|
|
197
|
+
catchup_in_progress: catchupInProgress,
|
|
198
|
+
another_run_needed: anotherRunNeeded,
|
|
199
|
+
last_synced_at: now(),
|
|
200
|
+
note,
|
|
201
|
+
};
|
|
202
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerFriendActionTools } from './tools/friends.js';
|
|
|
13
13
|
import { registerWishlistTools } from './tools/wishlist.js';
|
|
14
14
|
import { registerCheckinTools } from './tools/checkin.js';
|
|
15
15
|
import { registerUtilityTools } from './tools/utilities.js';
|
|
16
|
+
import { registerCacheTools } from './tools/cache.js';
|
|
16
17
|
// Build the env-based client once and inject it into each registrar. The
|
|
17
18
|
// constructor defers its config error, so the server still boots (and answers
|
|
18
19
|
// the host's install-time tools/list probe) when credentials are absent.
|
|
@@ -33,5 +34,9 @@ await runMcp({
|
|
|
33
34
|
(s) => registerWishlistTools(s, client),
|
|
34
35
|
(s) => registerCheckinTools(s, client),
|
|
35
36
|
(s) => registerUtilityTools(s, client),
|
|
37
|
+
// Cache tools live only on the Node/stdio server: they need a local SQLite
|
|
38
|
+
// file, which the filesystem-less Cloudflare Worker (src/worker.ts) can't
|
|
39
|
+
// provide, so they are intentionally NOT registered there.
|
|
40
|
+
(s) => registerCacheTools(s, client),
|
|
36
41
|
],
|
|
37
42
|
});
|