untappd-mcp 1.3.0 → 1.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.
@@ -0,0 +1,65 @@
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
+ import { CheckinStoreCore, LocalCacheStore } from './store.js';
7
+ // The `node:sqlite` backend for the check-in cache — a local on-disk SQLite file
8
+ // used by the stdio/desktop server. The query logic lives in CheckinStoreCore
9
+ // (src/cache/store.ts); this file only adapts `node:sqlite` to the SqlDriver
10
+ // surface and manages the file handle. (The remote Cloudflare connector uses a
11
+ // Durable Object backend instead — see src/cache/durable.ts.)
12
+ /** Adapts a `node:sqlite` DatabaseSync to the driver surface the core needs. */
13
+ class NodeSqlDriver {
14
+ db;
15
+ constructor(db) {
16
+ this.db = db;
17
+ }
18
+ execScript(sql) {
19
+ this.db.exec(sql);
20
+ }
21
+ run(sql, params) {
22
+ this.db.prepare(sql).run(...params);
23
+ }
24
+ get(sql, params) {
25
+ return this.db.prepare(sql).get(...params);
26
+ }
27
+ all(sql, params) {
28
+ return this.db.prepare(sql).all(...params);
29
+ }
30
+ transaction(fn) {
31
+ this.db.exec('BEGIN');
32
+ try {
33
+ fn();
34
+ this.db.exec('COMMIT');
35
+ }
36
+ catch (e) {
37
+ this.db.exec('ROLLBACK');
38
+ throw e;
39
+ }
40
+ }
41
+ }
42
+ /** A file-backed check-in cache. `open()` creates parent dirs and the schema. */
43
+ export class CheckinCache extends LocalCacheStore {
44
+ db;
45
+ constructor(db) {
46
+ super(new CheckinStoreCore(new NodeSqlDriver(db)));
47
+ this.db = db;
48
+ }
49
+ /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
50
+ static open(path) {
51
+ if (path !== ':memory:')
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ return new CheckinCache(new DatabaseSync(path));
54
+ }
55
+ close() {
56
+ this.db.close();
57
+ }
58
+ }
59
+ /** Default on-disk cache path: `$UNTAPPD_CACHE_DB` or `~/.untappd-mcp/checkins.db`. */
60
+ export function defaultCachePath() {
61
+ return readEnvVar('UNTAPPD_CACHE_DB') ?? join(homedir(), '.untappd-mcp', 'checkins.db');
62
+ }
63
+ // Re-export the shared types/helpers tests and tools import from here so their
64
+ // existing import paths keep working.
65
+ export { mapCheckinRow, } from './store.js';
@@ -0,0 +1,333 @@
1
+ // Storage-agnostic core for the Untappd check-in cache.
2
+ //
3
+ // The cache exists because the Untappd API only exposes paged, most-recent-first
4
+ // lists (50/page) and NO "has user X ever had beer Y" lookup — answering that
5
+ // from the API means paging the entire history every time, against a
6
+ // ~100-calls/hour rate limit. We sync once (incrementally + resumable backfill)
7
+ // into this cache, then answer every "has had" / filter query locally with zero
8
+ // API calls.
9
+ //
10
+ // The SQL logic lives here ONCE, parameterised over a tiny synchronous
11
+ // {@link SqlDriver}, so the exact same schema and queries back both storage
12
+ // engines: `node:sqlite` on the stdio server (src/cache/db.ts) and a Cloudflare
13
+ // Durable Object's SQLite storage on the remote connector (src/cache/durable.ts).
14
+ // This module imports nothing platform-specific so it is safe in both the Node
15
+ // and Workers bundles.
16
+ function asDict(v) {
17
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
18
+ }
19
+ function num(v) {
20
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
21
+ }
22
+ function str(v) {
23
+ return typeof v === 'string' && v.length > 0 ? v : null;
24
+ }
25
+ /**
26
+ * Normalise a raw `/user/checkins` item to a {@link CheckinRow}. Reads only the
27
+ * documented list fields (the same ones `compactCheckin` uses) and derives
28
+ * nothing it can't. `created_at` is normalised to ISO-8601 UTC so it sorts
29
+ * lexicographically = chronologically and date-range filters are plain string
30
+ * comparisons; the raw RFC-2822 string is kept if it can't be parsed.
31
+ */
32
+ export function mapCheckinRow(username, item) {
33
+ const c = asDict(item);
34
+ const checkin_id = num(c.checkin_id);
35
+ if (checkin_id === null)
36
+ return null; // can't key it — skip rather than corrupt the table
37
+ const beer = asDict(c.beer);
38
+ const brewery = asDict(c.brewery);
39
+ const venue = asDict(c.venue); // Untappd sends `venue: []` when none — asDict → {}
40
+ return {
41
+ checkin_id,
42
+ username,
43
+ bid: num(beer.bid),
44
+ beer_name: str(beer.beer_name),
45
+ brewery_id: num(brewery.brewery_id),
46
+ brewery_name: str(brewery.brewery_name),
47
+ beer_style: str(beer.beer_style),
48
+ abv: num(beer.beer_abv),
49
+ rating: num(c.rating_score),
50
+ comment: str(c.checkin_comment),
51
+ venue_id: num(venue.venue_id),
52
+ venue_name: str(venue.venue_name),
53
+ created_at: normaliseDate(c.created_at),
54
+ };
55
+ }
56
+ function normaliseDate(v) {
57
+ const s = str(v);
58
+ if (!s)
59
+ return null;
60
+ const t = Date.parse(s);
61
+ return Number.isNaN(t) ? s : new Date(t).toISOString();
62
+ }
63
+ /** Schema statements, split so a driver that only runs one statement per call works. */
64
+ export const SCHEMA_STATEMENTS = [
65
+ `CREATE TABLE IF NOT EXISTS checkins (
66
+ checkin_id INTEGER PRIMARY KEY,
67
+ username TEXT NOT NULL,
68
+ bid INTEGER,
69
+ beer_name TEXT,
70
+ brewery_id INTEGER,
71
+ brewery_name TEXT,
72
+ beer_style TEXT,
73
+ abv REAL,
74
+ rating REAL,
75
+ comment TEXT,
76
+ venue_id INTEGER,
77
+ venue_name TEXT,
78
+ created_at TEXT
79
+ )`,
80
+ `CREATE INDEX IF NOT EXISTS idx_checkins_user_bid ON checkins(username, bid)`,
81
+ `CREATE INDEX IF NOT EXISTS idx_checkins_user_brewery ON checkins(username, brewery_id)`,
82
+ `CREATE TABLE IF NOT EXISTS sync_state (
83
+ username TEXT PRIMARY KEY,
84
+ oldest_max_id INTEGER,
85
+ newest_checkin_id INTEGER,
86
+ catchup_max_id INTEGER,
87
+ last_synced_at TEXT,
88
+ backfill_complete INTEGER NOT NULL DEFAULT 0,
89
+ total_checkins INTEGER
90
+ )`,
91
+ ];
92
+ /** Columns added after the initial release, applied idempotently on open. */
93
+ export const MIGRATIONS = ['ALTER TABLE sync_state ADD COLUMN catchup_max_id INTEGER'];
94
+ /**
95
+ * The check-in cache logic over a synchronous {@link SqlDriver}. Usernames are
96
+ * keyed case-insensitively via a lowercased key so `Mer1331` and `mer1331` share
97
+ * one cache; stored rows keep whatever casing they arrived with in other fields.
98
+ */
99
+ export class CheckinStoreCore {
100
+ db;
101
+ constructor(db) {
102
+ this.db = db;
103
+ for (const stmt of SCHEMA_STATEMENTS)
104
+ this.db.execScript(stmt);
105
+ // Forward-compat for a sync_state table created before a column existed; the
106
+ // ADD COLUMN throws harmlessly if the column is already present.
107
+ for (const mig of MIGRATIONS) {
108
+ try {
109
+ this.db.execScript(mig);
110
+ }
111
+ catch {
112
+ /* column already exists */
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Upsert a batch of rows (dedupe on checkin_id) inside one transaction and
118
+ * return the NET number of newly-inserted rows (updates to existing rows count
119
+ * as 0), which is what "rows added" reports.
120
+ */
121
+ upsertCheckins(username, rows) {
122
+ if (rows.length === 0)
123
+ return 0;
124
+ const key = username.toLowerCase();
125
+ const before = this.cachedCount(username);
126
+ const sql = `
127
+ INSERT INTO checkins
128
+ (checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
130
+ ON CONFLICT(checkin_id) DO UPDATE SET
131
+ username=excluded.username, bid=excluded.bid, beer_name=excluded.beer_name,
132
+ brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
133
+ abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
134
+ venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at`;
135
+ this.db.transaction(() => {
136
+ for (const r of rows) {
137
+ this.db.run(sql, [
138
+ r.checkin_id,
139
+ key,
140
+ r.bid,
141
+ r.beer_name,
142
+ r.brewery_id,
143
+ r.brewery_name,
144
+ r.beer_style,
145
+ r.abv,
146
+ r.rating,
147
+ r.comment,
148
+ r.venue_id,
149
+ r.venue_name,
150
+ r.created_at,
151
+ ]);
152
+ }
153
+ });
154
+ return this.cachedCount(username) - before;
155
+ }
156
+ /** Total cached check-ins for a user (= distinct check-ins, since PK is checkin_id). */
157
+ cachedCount(username) {
158
+ const row = this.db.get('SELECT COUNT(*) AS n FROM checkins WHERE username = ?', [username.toLowerCase()]);
159
+ return Number(row?.n ?? 0);
160
+ }
161
+ /** Distinct beers (unique bids) cached for a user. */
162
+ distinctBeerCount(username) {
163
+ const row = this.db.get('SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL', [username.toLowerCase()]);
164
+ return Number(row?.n ?? 0);
165
+ }
166
+ /** Highest cached checkin_id for a user (the true newest once contiguous), or null. */
167
+ newestCachedId(username) {
168
+ const row = this.db.get('SELECT MAX(checkin_id) AS m FROM checkins WHERE username = ?', [username.toLowerCase()]);
169
+ return num(row?.m);
170
+ }
171
+ getState(username) {
172
+ const row = this.db.get('SELECT * FROM sync_state WHERE username = ?', [username.toLowerCase()]);
173
+ if (!row)
174
+ return undefined;
175
+ return {
176
+ username: String(row.username),
177
+ oldest_max_id: num(row.oldest_max_id),
178
+ newest_checkin_id: num(row.newest_checkin_id),
179
+ catchup_max_id: num(row.catchup_max_id),
180
+ last_synced_at: str(row.last_synced_at),
181
+ backfill_complete: Number(row.backfill_complete) === 1,
182
+ total_checkins: num(row.total_checkins),
183
+ };
184
+ }
185
+ /**
186
+ * Insert-or-merge sync state. Only keys PRESENT in `patch` are written, so an
187
+ * explicit `null` (e.g. clearing `oldest_max_id` when backfill completes)
188
+ * overrides the stored value — a `??` merge could not do that.
189
+ */
190
+ setState(username, patch) {
191
+ const key = username.toLowerCase();
192
+ const cur = this.getState(key);
193
+ const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
194
+ const next = {
195
+ username: key,
196
+ oldest_max_id: pick('oldest_max_id', null),
197
+ newest_checkin_id: pick('newest_checkin_id', null),
198
+ catchup_max_id: pick('catchup_max_id', null),
199
+ last_synced_at: pick('last_synced_at', null),
200
+ backfill_complete: pick('backfill_complete', false),
201
+ total_checkins: pick('total_checkins', null),
202
+ };
203
+ this.db.run(`INSERT INTO sync_state (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins)
204
+ VALUES (?, ?, ?, ?, ?, ?, ?)
205
+ ON CONFLICT(username) DO UPDATE SET
206
+ oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
207
+ catchup_max_id=excluded.catchup_max_id,
208
+ last_synced_at=excluded.last_synced_at, backfill_complete=excluded.backfill_complete,
209
+ total_checkins=excluded.total_checkins`, [
210
+ key,
211
+ next.oldest_max_id,
212
+ next.newest_checkin_id,
213
+ next.catchup_max_id,
214
+ next.last_synced_at,
215
+ next.backfill_complete ? 1 : 0,
216
+ next.total_checkins,
217
+ ]);
218
+ }
219
+ /** Exact bid match or case-insensitive substring match on beer name. */
220
+ hasHad(username, opts) {
221
+ const where = ['username = ?'];
222
+ const params = [username.toLowerCase()];
223
+ if (opts.bid !== undefined) {
224
+ where.push('bid = ?');
225
+ params.push(opts.bid);
226
+ }
227
+ if (opts.beerName !== undefined) {
228
+ where.push('beer_name LIKE ? COLLATE NOCASE');
229
+ params.push(`%${escapeLike(opts.beerName)}%`);
230
+ }
231
+ const clause = where.join(' AND ');
232
+ const agg = this.db.get(`SELECT COUNT(*) AS n, MAX(rating) AS best FROM checkins WHERE ${clause}`, params);
233
+ // Most-recent first: checkin_id is monotonic in time, a reliable ordering
234
+ // even though created_at is a formatted string.
235
+ const matches = this.db.all(`SELECT checkin_id, bid, beer_name, rating, venue_name, created_at
236
+ FROM checkins WHERE ${clause} ORDER BY checkin_id DESC LIMIT 25`, params);
237
+ const count = Number(agg?.n ?? 0);
238
+ return {
239
+ had: count > 0,
240
+ count,
241
+ last_date: matches[0]?.created_at ?? null,
242
+ best_rating: num(agg?.best),
243
+ matches,
244
+ };
245
+ }
246
+ query(username, filters) {
247
+ const where = ['username = ?'];
248
+ const params = [username.toLowerCase()];
249
+ if (filters.brewery_id !== undefined) {
250
+ where.push('brewery_id = ?');
251
+ params.push(filters.brewery_id);
252
+ }
253
+ if (filters.brewery !== undefined) {
254
+ where.push('brewery_name LIKE ? COLLATE NOCASE');
255
+ params.push(`%${escapeLike(filters.brewery)}%`);
256
+ }
257
+ if (filters.style !== undefined) {
258
+ where.push('beer_style LIKE ? COLLATE NOCASE');
259
+ params.push(`%${escapeLike(filters.style)}%`);
260
+ }
261
+ if (filters.min_rating !== undefined) {
262
+ where.push('rating >= ?');
263
+ params.push(filters.min_rating);
264
+ }
265
+ if (filters.venue !== undefined) {
266
+ where.push('venue_name LIKE ? COLLATE NOCASE');
267
+ params.push(`%${escapeLike(filters.venue)}%`);
268
+ }
269
+ if (filters.venue_id !== undefined) {
270
+ where.push('venue_id = ?');
271
+ params.push(filters.venue_id);
272
+ }
273
+ // created_at is ISO-8601, so a YYYY-MM-DD bound compares correctly on its date prefix.
274
+ if (filters.date_from !== undefined) {
275
+ where.push('substr(created_at, 1, 10) >= ?');
276
+ params.push(filters.date_from);
277
+ }
278
+ if (filters.date_to !== undefined) {
279
+ where.push('substr(created_at, 1, 10) <= ?');
280
+ params.push(filters.date_to);
281
+ }
282
+ const order = filters.sort === 'oldest'
283
+ ? 'checkin_id ASC'
284
+ : filters.sort === 'highest_rated'
285
+ ? 'rating DESC, checkin_id DESC'
286
+ : filters.sort === 'lowest_rated'
287
+ ? 'rating ASC, checkin_id DESC'
288
+ : 'checkin_id DESC';
289
+ const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
290
+ return this.db.all(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`, [...params, limit]);
291
+ }
292
+ }
293
+ /**
294
+ * Adapts a synchronous {@link CheckinStoreCore} to the async {@link CacheStore}
295
+ * interface. Used by the in-process node backend; the Durable Object backend
296
+ * implements CacheStore over a real RPC boundary instead.
297
+ */
298
+ export class LocalCacheStore {
299
+ core;
300
+ constructor(core) {
301
+ this.core = core;
302
+ }
303
+ async upsertCheckins(username, rows) {
304
+ return this.core.upsertCheckins(username, rows);
305
+ }
306
+ async cachedCount(username) {
307
+ return this.core.cachedCount(username);
308
+ }
309
+ async distinctBeerCount(username) {
310
+ return this.core.distinctBeerCount(username);
311
+ }
312
+ async newestCachedId(username) {
313
+ return this.core.newestCachedId(username);
314
+ }
315
+ async getState(username) {
316
+ return this.core.getState(username);
317
+ }
318
+ async setState(username, patch) {
319
+ this.core.setState(username, patch);
320
+ }
321
+ async hasHad(username, opts) {
322
+ return this.core.hasHad(username, opts);
323
+ }
324
+ async query(username, filters) {
325
+ return this.core.query(username, filters);
326
+ }
327
+ }
328
+ // Escape LIKE wildcards in user input so a literal % or _ isn't treated as a
329
+ // wildcard. Our LIKE patterns don't set an ESCAPE clause, so we simply strip the
330
+ // specials rather than rely on a non-portable escape convention.
331
+ function escapeLike(s) {
332
+ return s.replace(/[%_]/g, ' ');
333
+ }
@@ -0,0 +1,202 @@
1
+ import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
+ import { mapCheckinRow } from './store.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 = await 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 += await 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
+ await 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 = await cache.newestCachedId(rawUsername);
118
+ catchupMaxId = null;
119
+ catchupInProgress = false;
120
+ await 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 += await 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
+ await 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 = await cache.cachedCount(rawUsername);
167
+ await 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: await 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,10 +13,17 @@ 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';
17
+ import { logRegisteredTools } from './tools/diagnostics.js';
18
+ import { CheckinCache, defaultCachePath } from './cache/db.js';
16
19
  // Build the env-based client once and inject it into each registrar. The
17
20
  // constructor defers its config error, so the server still boots (and answers
18
21
  // the host's install-time tools/list probe) when credentials are absent.
19
22
  const client = new UntappdClient();
23
+ // The stdio server backs the cache with a local `node:sqlite` file, opened lazily
24
+ // on first cache-tool use so the server still boots when no path is configured.
25
+ let nodeCache;
26
+ const nodeCacheProvider = () => (nodeCache ??= CheckinCache.open(defaultCachePath()));
20
27
  await runMcp({
21
28
  name: 'untappd-mcp',
22
29
  version: VERSION,
@@ -33,5 +40,11 @@ await runMcp({
33
40
  (s) => registerWishlistTools(s, client),
34
41
  (s) => registerCheckinTools(s, client),
35
42
  (s) => registerUtilityTools(s, client),
43
+ // The stdio server backs the cache with a local `node:sqlite` file; the
44
+ // remote Cloudflare connector (src/worker.ts) backs the same cache tools
45
+ // with a per-user Durable Object instead.
46
+ (s) => registerCacheTools(s, client, nodeCacheProvider),
47
+ // Keep last: logs the full registered toolset (count + names) at startup.
48
+ (s) => logRegisteredTools(s, 'stdio'),
36
49
  ],
37
50
  });