untappd-mcp 2.4.0 → 2.5.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.
@@ -1,5 +1,6 @@
1
1
  import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
2
  import { mapBeerRow } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
3
4
  const PAGE_LIMIT = 50; // Untappd's max page size for /user/beers.
4
5
  // A resumed backfill re-reads this many already-fetched items: if beers dropped
5
6
  // out of the list between runs, everything below shifted UP past the stored
@@ -65,8 +66,25 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
65
66
  }
66
67
  };
67
68
  const gapNow = async () => total === null ? 0 : total - (await cache.distinctBeersCount(rawUsername));
69
+ let rateStop;
68
70
  for (; pages < maxPages && !complete;) {
69
- const page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client, encodedUser, offset);
71
+ // Stop BEFORE a call that would run into the hourly limit; progress so far
72
+ // is already persisted (setState after every page), so the next run resumes.
73
+ const verdict = await checkRateBudget(client, cache);
74
+ if (!verdict.ok) {
75
+ rateStop = stopFromVerdict(verdict);
76
+ break;
77
+ }
78
+ let page;
79
+ try {
80
+ page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client, encodedUser, offset);
81
+ }
82
+ catch (e) {
83
+ if (!(e instanceof RateLimitError))
84
+ throw e;
85
+ rateStop = stopFrom429(e);
86
+ break;
87
+ }
70
88
  pages++;
71
89
  if (page.total !== null)
72
90
  total = page.total;
@@ -146,7 +164,9 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
146
164
  const anotherRunNeeded = !complete;
147
165
  const note = complete
148
166
  ? 'Full distinct-beers list is cached — has-had coverage is complete for this user. Re-run occasionally to pick up newly-had beers.'
149
- : `Distinct-beers sync in progress (${cachedBeers}${total !== null ? `/${total}` : ''}). Run untappd_sync_user_beers again to fetch the next ${maxPages} pages.`;
167
+ : rateStop
168
+ ? `Distinct-beers sync paused (${cachedBeers}${total !== null ? `/${total}` : ''}). ${resumeAdvice(rateStop, 'untappd_sync_user_beers')}`
169
+ : `Distinct-beers sync in progress (${cachedBeers}${total !== null ? `/${total}` : ''}). Run untappd_sync_user_beers again to fetch the next ${maxPages} pages.`;
150
170
  return {
151
171
  username: rawUsername,
152
172
  beers_added: added,
@@ -158,6 +178,8 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
158
178
  incremental,
159
179
  stopped_at_bid: stoppedAtBid,
160
180
  another_run_needed: anotherRunNeeded,
181
+ stopped_for_rate_limit: rateStop !== undefined,
182
+ ...(rateStop ? { rate_limit: rateStop } : {}),
161
183
  last_synced_at: now(),
162
184
  note,
163
185
  };
@@ -0,0 +1,125 @@
1
+ import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
+ import { mapBreweryLikeItem } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
4
+ /** The `list_sync_state` key for a user's brewery-likes cursor (literal also in store.ts breweryLikesCoverage). */
5
+ export const BREWERY_LIKES_LIST = 'brewery_likes';
6
+ /**
7
+ * The `list_sync_state` key whose `last_synced_at` holds the CURRENT pass's
8
+ * stamp (same scheme as the wishlist mirror): rows a finished pass did not
9
+ * stamp were unliked upstream.
10
+ */
11
+ export const BREWERY_LIKES_PASS_LIST = 'brewery_likes_pass';
12
+ /** user/brewerylikes honours limit=50 (live, 6a); still advance by what it returns. */
13
+ export const BREWERY_LIKES_PAGE_REQUEST = 50;
14
+ /** A resumed pass re-reads this many items (the list may have shifted up between runs). */
15
+ export const BREWERY_LIKES_RESUME_OVERLAP = 5;
16
+ export const BREWERY_LIKES_MAX_PAGES_CAP = 50;
17
+ const offsetOf = (po) => typeof po === 'number' ? po : typeof po === 'string' && /^\d+$/.test(po) ? Number(po) : null;
18
+ /**
19
+ * Mirror the breweries a user "likes" on Untappd (user/brewerylikes — the
20
+ * app's "following"; works for non-self users) into `brewery_likes`, where they
21
+ * become the "untappd" favorite source. Same shape as the wishlist mirror: each
22
+ * sync is a full pass from the top, resumable across runs with the cursor
23
+ * persisted every page and the stop-before-429 guard before every call; rows
24
+ * are stamped with the pass and only a FINISHED pass removes unliked breweries.
25
+ */
26
+ export async function syncBreweryLikes(client, cache, rawUsername, maxPages = 10) {
27
+ const pagesBudget = Math.max(1, Math.min(BREWERY_LIKES_MAX_PAGES_CAP, Math.floor(maxPages)));
28
+ const encodedUser = encodeURIComponent(rawUsername);
29
+ const now = () => new Date().toISOString();
30
+ const prior = await cache.getListState(rawUsername, BREWERY_LIKES_LIST);
31
+ const passRow = await cache.getListState(rawUsername, BREWERY_LIKES_PASS_LIST);
32
+ const resuming = !!prior && !prior.complete && prior.next_offset !== null && !!passRow?.last_synced_at;
33
+ let passStamp;
34
+ let offset;
35
+ if (resuming) {
36
+ passStamp = passRow.last_synced_at;
37
+ offset = Math.max(0, prior.next_offset - BREWERY_LIKES_RESUME_OVERLAP);
38
+ }
39
+ else {
40
+ const prev = passRow?.last_synced_at ? Date.parse(passRow.last_synced_at) : 0;
41
+ passStamp = new Date(Math.max(Date.now(), prev + 1)).toISOString();
42
+ offset = 0;
43
+ await cache.setListState(rawUsername, BREWERY_LIKES_PASS_LIST, { last_synced_at: passStamp, complete: false, next_offset: null });
44
+ }
45
+ let total = prior?.total_count ?? null;
46
+ let pages = 0;
47
+ let added = 0;
48
+ let removed = 0;
49
+ let complete = false;
50
+ let rateStop;
51
+ while (pages < pagesBudget && !complete) {
52
+ const verdict = await checkRateBudget(client, cache);
53
+ if (!verdict.ok) {
54
+ rateStop = stopFromVerdict(verdict);
55
+ break;
56
+ }
57
+ let data;
58
+ try {
59
+ data = await client.get(`/user/brewerylikes/${encodedUser}`, { limit: BREWERY_LIKES_PAGE_REQUEST, offset });
60
+ }
61
+ catch (e) {
62
+ if (e instanceof RateLimitError) {
63
+ rateStop = stopFrom429(e);
64
+ break;
65
+ }
66
+ if (pages > 0)
67
+ throw e;
68
+ throw createHelpfulError(`Could not fetch the liked breweries for "${rawUsername}": ${messageOf(e)}`, {
69
+ hint: "Untappd only returns another user's lists if their account is public or they're your friend.",
70
+ });
71
+ }
72
+ pages++;
73
+ const items = Array.isArray(data?.items) ? data.items : [];
74
+ if (typeof data?.total_count === 'number')
75
+ total = data.total_count;
76
+ const rows = [];
77
+ for (const it of items) {
78
+ const r = mapBreweryLikeItem(it);
79
+ if (r)
80
+ rows.push(r);
81
+ }
82
+ added += await cache.upsertBreweryLikes(rawUsername, rows, passStamp);
83
+ // pagination.offset is ABSENT on an empty list (live, 6a) — fall back to offset + items.
84
+ const next = offsetOf(data?.pagination?.offset);
85
+ const advanced = next !== null && next > offset ? next : offset + items.length;
86
+ // Without a total, a short page is not proof of the end (a route may serve fewer
87
+ // than it was asked for), so only an empty page ends the pass.
88
+ complete = items.length === 0 || (total !== null && advanced >= total);
89
+ offset = advanced;
90
+ if (complete)
91
+ removed = await cache.pruneBreweryLikes(rawUsername, passStamp);
92
+ await cache.setListState(rawUsername, BREWERY_LIKES_LIST, {
93
+ next_offset: complete ? null : offset,
94
+ total_count: total,
95
+ complete,
96
+ last_synced_at: now(),
97
+ });
98
+ if (complete)
99
+ await cache.setListState(rawUsername, BREWERY_LIKES_PASS_LIST, { complete: true });
100
+ }
101
+ const cached = await cache.breweryLikesCount(rawUsername);
102
+ const percent = total !== null && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null;
103
+ const progress = `${cached}${total !== null ? `/${total}` : ''}`;
104
+ const note = complete
105
+ ? `Liked breweries mirrored (${progress}). They now count as favorites with source "untappd". Re-run to pick up changes — each sync is a fresh pass from the top.`
106
+ : rateStop
107
+ ? `Brewery-likes sync paused (pass at offset ${offset}). ${resumeAdvice(rateStop, 'untappd_sync_brewery_likes')}`
108
+ : `Brewery-likes sync in progress (pass at offset ${offset}). Run untappd_sync_brewery_likes again to continue; unlikes apply when the pass finishes.`;
109
+ return {
110
+ username: rawUsername,
111
+ likes_added: added,
112
+ likes_removed: removed,
113
+ pages_fetched: pages,
114
+ cached,
115
+ total,
116
+ percent,
117
+ complete,
118
+ another_run_needed: !complete,
119
+ next_offset: complete ? null : offset,
120
+ stopped_for_rate_limit: rateStop !== undefined,
121
+ ...(rateStop ? { rate_limit: rateStop } : {}),
122
+ last_synced_at: now(),
123
+ note,
124
+ };
125
+ }
@@ -0,0 +1,200 @@
1
+ import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
+ import { mapUserVenueItem } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
4
+ /** The `list_sync_state` key for a user's venue history. */
5
+ export const VENUES_LIST = 'venues';
6
+ /**
7
+ * The `list_sync_state` key for an incremental refresh in flight. A row with
8
+ * `complete = false` means a refresh was cut short (max_pages, the rate guard
9
+ * or a 429) before it reached its stop page; `next_offset` is where it resumes.
10
+ * Without it an interrupted refresh would restart at 0, find the pages it had
11
+ * already upserted unchanged, and stop short of the changes further down.
12
+ */
13
+ export const VENUES_REFRESH_LIST = 'venues_refresh';
14
+ /**
15
+ * What we ASK for per page. Untappd returns at most 25 venues whatever the
16
+ * limit (live, 2026-11), so the cursor advances by the returned
17
+ * `pagination.offset` — never by this number.
18
+ */
19
+ export const VENUES_PAGE_REQUEST = 50;
20
+ /** The most venues a page has been seen to hold; a shorter page is the last one. */
21
+ const VENUES_SERVER_PAGE_MAX = 25;
22
+ /**
23
+ * A resumed backfill re-reads this many already-fetched venues: if the list
24
+ * shifted up between runs (a venue dropped out), the ones that crossed the
25
+ * stored offset would otherwise be skipped. Upserts are idempotent.
26
+ */
27
+ export const VENUES_RESUME_OVERLAP = 5;
28
+ /** Hard cap on pages per call, whatever the caller asks. */
29
+ export const VENUES_MAX_PAGES_CAP = 50;
30
+ async function fetchVenuesPage(client, encodedUser, offset) {
31
+ const data = await client.get(`/user/venues/${encodedUser}`, { limit: VENUES_PAGE_REQUEST, offset, sort: 'date' });
32
+ const items = Array.isArray(data?.venues?.items)
33
+ ? data.venues.items
34
+ : Array.isArray(data?.items)
35
+ ? data.items
36
+ : [];
37
+ const total = typeof data?.total_count === 'number'
38
+ ? data.total_count
39
+ : typeof data?.venues?.total_count === 'number'
40
+ ? data.venues.total_count
41
+ : null;
42
+ const po = data?.pagination?.offset;
43
+ const nextOffset = typeof po === 'number' ? po : typeof po === 'string' && /^\d+$/.test(po) ? Number(po) : null;
44
+ return { items, total, nextOffset };
45
+ }
46
+ /**
47
+ * Sync a user's complete venue history (user/venues) into `venues` +
48
+ * `user_venues`. Unlike user/checkins, user/venues pages a NON-self account's
49
+ * whole history, so this is how "has this person been here?" gets answered.
50
+ *
51
+ * Resumable backfill: offset-paged (sort=date), the cursor and total persisted
52
+ * after EVERY page, at most `maxPages` (capped at 50) per call, stopping before
53
+ * the hourly rate limit. Once the whole list is cached, a re-run is an
54
+ * incremental refresh: page from offset 0 and stop at the first page on which
55
+ * every venue's recent_visit (and visit count) is unchanged. A refresh cut short
56
+ * before that stop page persists its own cursor and the next run resumes it
57
+ * (the history counts as incomplete meanwhile); only a finished refresh
58
+ * restarts at 0.
59
+ */
60
+ export async function syncUserVenues(client, cache, rawUsername, maxPages = 10) {
61
+ const pagesBudget = Math.max(1, Math.min(VENUES_MAX_PAGES_CAP, Math.floor(maxPages)));
62
+ const encodedUser = encodeURIComponent(rawUsername);
63
+ const now = () => new Date().toISOString();
64
+ const prior = await cache.getListState(rawUsername, VENUES_LIST);
65
+ const incremental = (prior?.complete ?? false) && !!prior?.last_synced_at;
66
+ const refreshPrior = incremental ? await cache.getListState(rawUsername, VENUES_REFRESH_LIST) : undefined;
67
+ const resumeFrom = (o) => Math.max(0, (o ?? 0) - (o ? VENUES_RESUME_OVERLAP : 0));
68
+ // A finished refresh (or none yet) starts at the top; an unfinished one resumes.
69
+ let offset = incremental
70
+ ? refreshPrior && !refreshPrior.complete
71
+ ? resumeFrom(refreshPrior.next_offset)
72
+ : 0
73
+ : resumeFrom(prior?.next_offset);
74
+ let total = prior?.total_count ?? null;
75
+ let complete = false; // backfill: the whole list cached; refresh: the stop page (or the end) reached
76
+ let done = false;
77
+ let pages = 0;
78
+ let added = 0;
79
+ let updated = 0;
80
+ let rateStop;
81
+ while (pages < pagesBudget && !done) {
82
+ const verdict = await checkRateBudget(client, cache);
83
+ if (!verdict.ok) {
84
+ rateStop = stopFromVerdict(verdict);
85
+ break;
86
+ }
87
+ let page;
88
+ try {
89
+ page = await fetchVenuesPage(client, encodedUser, offset);
90
+ }
91
+ catch (e) {
92
+ if (e instanceof RateLimitError) {
93
+ rateStop = stopFrom429(e);
94
+ break;
95
+ }
96
+ if (pages > 0)
97
+ throw e;
98
+ throw createHelpfulError(`Could not fetch the venue history for "${rawUsername}": ${messageOf(e)}`, {
99
+ hint: "Untappd only returns another user's venues if their account is public or they're your friend. If the account is private, add them as a friend (untappd_add_friend) first.",
100
+ });
101
+ }
102
+ pages++;
103
+ if (page.total !== null)
104
+ total = page.total;
105
+ const mapped = [];
106
+ for (const it of page.items) {
107
+ const m = mapUserVenueItem(rawUsername, it);
108
+ if (m)
109
+ mapped.push(m);
110
+ }
111
+ // Incremental: a page on which every venue is already cached with the same
112
+ // recent_visit and count is where this refresh meets the last one — the
113
+ // list is newest-visit first, so nothing below it has changed.
114
+ let unchangedPage = false;
115
+ if (incremental && mapped.length > 0) {
116
+ const known = new Map((await cache.getUserVenueVisits(rawUsername, mapped.map((m) => m.visit.venue_id))).map((r) => [r.venue_id, r]));
117
+ unchangedPage = mapped.every((m) => {
118
+ const k = known.get(m.visit.venue_id);
119
+ return k !== undefined && k.recent_visit === m.visit.recent_visit && k.total_count === m.visit.total_count;
120
+ });
121
+ }
122
+ if (!unchangedPage && mapped.length > 0) {
123
+ const r = await cache.upsertUserVenues(rawUsername, mapped);
124
+ added += r.added;
125
+ updated += r.updated;
126
+ }
127
+ // Advance by what Untappd says the next offset is (it serves 25 per page
128
+ // even when asked for 50); fall back to counting the items it sent.
129
+ const advanced = page.nextOffset !== null && page.nextOffset > offset ? page.nextOffset : offset + page.items.length;
130
+ const atEnd = page.items.length === 0 ||
131
+ (total !== null ? advanced >= total : page.items.length < VENUES_SERVER_PAGE_MAX);
132
+ offset = advanced;
133
+ if (unchangedPage || atEnd) {
134
+ done = true;
135
+ complete = true;
136
+ }
137
+ if (incremental) {
138
+ // The backfill row stays complete (its cursor at the end of the list);
139
+ // the refresh's own progress lives in VENUES_REFRESH_LIST.
140
+ await cache.setListState(rawUsername, VENUES_REFRESH_LIST, {
141
+ next_offset: complete ? null : offset,
142
+ total_count: total,
143
+ complete,
144
+ last_synced_at: now(),
145
+ });
146
+ await cache.setListState(rawUsername, VENUES_LIST, {
147
+ next_offset: total ?? offset,
148
+ total_count: total,
149
+ complete: true,
150
+ ...(complete ? { last_synced_at: now() } : {}),
151
+ });
152
+ }
153
+ else {
154
+ await cache.setListState(rawUsername, VENUES_LIST, {
155
+ next_offset: complete ? (total ?? offset) : offset,
156
+ total_count: total,
157
+ complete,
158
+ last_synced_at: now(),
159
+ });
160
+ }
161
+ }
162
+ // A refresh that fetched nothing (rate-stopped before page 1) leaves any
163
+ // earlier unfinished refresh as it was; if there was none, the history is
164
+ // as complete as after the last finished refresh — but this one still has
165
+ // to run.
166
+ const refreshInProgress = incremental && (pages > 0 ? !complete : !!refreshPrior && !refreshPrior.complete);
167
+ const historyComplete = incremental ? !refreshInProgress : complete;
168
+ const cached = await cache.userVenuesCount(rawUsername);
169
+ const percent = total && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : historyComplete ? 100 : null;
170
+ const anotherRunNeeded = !complete;
171
+ const progress = `${cached}${total !== null ? `/${total}` : ''}`;
172
+ const note = complete
173
+ ? incremental
174
+ ? `Venue history refreshed (${progress} cached). Re-run occasionally to pick up new visits.`
175
+ : `Full venue history is cached (${progress}) — untappd_cache_has_visited answers are complete for this user.`
176
+ : refreshInProgress
177
+ ? `Venue history refresh unfinished (${progress} cached; resumes at offset ${offset}) — re-visited or new venues further down the list may not be cached yet. ${rateStop ? resumeAdvice(rateStop, 'untappd_sync_user_venues') : 'Run untappd_sync_user_venues again to continue the refresh.'}`
178
+ : rateStop
179
+ ? `Venue history sync paused (${progress}). ${resumeAdvice(rateStop, 'untappd_sync_user_venues')}`
180
+ : `Venue history sync in progress (${progress}). Run untappd_sync_user_venues again to fetch the next ${pagesBudget} pages.`;
181
+ const state = await cache.getListState(rawUsername, VENUES_LIST);
182
+ return {
183
+ username: rawUsername,
184
+ venues_added: added,
185
+ venues_updated: updated,
186
+ pages_fetched: pages,
187
+ cached_venues: cached,
188
+ total_venues: total,
189
+ backfill_percent: percent,
190
+ venues_complete: historyComplete,
191
+ incremental,
192
+ refresh_in_progress: refreshInProgress,
193
+ another_run_needed: anotherRunNeeded,
194
+ next_offset: refreshInProgress ? offset : (state?.next_offset ?? null),
195
+ stopped_for_rate_limit: rateStop !== undefined,
196
+ ...(rateStop ? { rate_limit: rateStop } : {}),
197
+ last_synced_at: now(),
198
+ note,
199
+ };
200
+ }
@@ -0,0 +1,126 @@
1
+ import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
+ import { mapWishlistItem } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
4
+ /** The `list_sync_state` key for a user's wishlist cursor (literal also in store.ts wishlistCoverage). */
5
+ export const WISHLIST_LIST = 'wishlist';
6
+ /**
7
+ * The `list_sync_state` key whose `last_synced_at` holds the CURRENT pass's
8
+ * stamp. Every row a pass sees is stamped with it, so when the pass reaches the
9
+ * end, rows carrying any other stamp were removed from the wishlist upstream.
10
+ */
11
+ export const WISHLIST_PASS_LIST = 'wishlist_pass';
12
+ /** What we ask for per page; Untappd may serve fewer, so advance by what it returns. */
13
+ export const WISHLIST_PAGE_REQUEST = 50;
14
+ const WISHLIST_SERVER_PAGE_MAX = 25;
15
+ /** A resumed pass re-reads this many items (the list may have shifted up between runs). */
16
+ export const WISHLIST_RESUME_OVERLAP = 5;
17
+ export const WISHLIST_MAX_PAGES_CAP = 50;
18
+ /**
19
+ * Mirror a user's wishlist (user/wishlist) into the `wishlist` table so pick
20
+ * tools can boost wishlisted beers without an API call. Wishlists change in
21
+ * both directions, so each sync is a full pass from the top (they are small:
22
+ * ~25 beers per call), resumable across runs with the cursor persisted every
23
+ * page and the stop-before-429 guard before every call. Removals are applied
24
+ * only when a pass reaches the end — a partial pass never deletes.
25
+ */
26
+ export async function syncWishlist(client, cache, rawUsername, maxPages = 10) {
27
+ const pagesBudget = Math.max(1, Math.min(WISHLIST_MAX_PAGES_CAP, Math.floor(maxPages)));
28
+ const encodedUser = encodeURIComponent(rawUsername);
29
+ const now = () => new Date().toISOString();
30
+ const prior = await cache.getListState(rawUsername, WISHLIST_LIST);
31
+ const passRow = await cache.getListState(rawUsername, WISHLIST_PASS_LIST);
32
+ // Resume an unfinished pass; otherwise start a new one from the top, with a
33
+ // stamp strictly later than the previous pass's so old rows never share it.
34
+ const resuming = !!prior && !prior.complete && prior.next_offset !== null && !!passRow?.last_synced_at;
35
+ let passStamp;
36
+ let offset;
37
+ if (resuming) {
38
+ passStamp = passRow.last_synced_at;
39
+ offset = Math.max(0, prior.next_offset - WISHLIST_RESUME_OVERLAP);
40
+ }
41
+ else {
42
+ const prev = passRow?.last_synced_at ? Date.parse(passRow.last_synced_at) : 0;
43
+ passStamp = new Date(Math.max(Date.now(), prev + 1)).toISOString();
44
+ offset = 0;
45
+ await cache.setListState(rawUsername, WISHLIST_PASS_LIST, { last_synced_at: passStamp, complete: false, next_offset: null });
46
+ }
47
+ let total = prior?.total_count ?? null;
48
+ let pages = 0;
49
+ let added = 0;
50
+ let removed = 0;
51
+ let complete = false;
52
+ let rateStop;
53
+ while (pages < pagesBudget && !complete) {
54
+ const verdict = await checkRateBudget(client, cache);
55
+ if (!verdict.ok) {
56
+ rateStop = stopFromVerdict(verdict);
57
+ break;
58
+ }
59
+ let data;
60
+ try {
61
+ data = await client.get(`/user/wishlist/${encodedUser}`, { limit: WISHLIST_PAGE_REQUEST, offset, sort: 'date' });
62
+ }
63
+ catch (e) {
64
+ if (e instanceof RateLimitError) {
65
+ rateStop = stopFrom429(e);
66
+ break;
67
+ }
68
+ if (pages > 0)
69
+ throw e;
70
+ throw createHelpfulError(`Could not fetch the wishlist for "${rawUsername}": ${messageOf(e)}`, {
71
+ hint: "Untappd only returns another user's wishlist if their account is public or they're your friend.",
72
+ });
73
+ }
74
+ pages++;
75
+ const items = Array.isArray(data?.beers?.items) ? data.beers.items : [];
76
+ if (typeof data?.total_count === 'number')
77
+ total = data.total_count;
78
+ else if (typeof data?.beers?.total_count === 'number')
79
+ total = data.beers.total_count;
80
+ const rows = [];
81
+ for (const it of items) {
82
+ const r = mapWishlistItem(it);
83
+ if (r)
84
+ rows.push(r);
85
+ }
86
+ added += await cache.upsertWishlist(rawUsername, rows, passStamp);
87
+ const po = data?.pagination?.offset;
88
+ const next = typeof po === 'number' ? po : typeof po === 'string' && /^\d+$/.test(po) ? Number(po) : null;
89
+ const advanced = next !== null && next > offset ? next : offset + items.length;
90
+ complete =
91
+ items.length === 0 || (total !== null ? advanced >= total : items.length < WISHLIST_SERVER_PAGE_MAX);
92
+ offset = advanced;
93
+ if (complete)
94
+ removed = await cache.pruneWishlist(rawUsername, passStamp);
95
+ await cache.setListState(rawUsername, WISHLIST_LIST, {
96
+ next_offset: complete ? null : offset,
97
+ total_count: total,
98
+ complete,
99
+ last_synced_at: now(),
100
+ });
101
+ if (complete)
102
+ await cache.setListState(rawUsername, WISHLIST_PASS_LIST, { complete: true });
103
+ }
104
+ const cached = await cache.wishlistCount(rawUsername);
105
+ const progress = `${cached}${total !== null ? `/${total}` : ''}`;
106
+ const note = complete
107
+ ? `Wishlist mirrored (${progress} beers). Re-run to pick up changes — each sync is a fresh pass from the top.`
108
+ : rateStop
109
+ ? `Wishlist sync paused (pass at offset ${offset}). ${resumeAdvice(rateStop, 'untappd_sync_wishlist')}`
110
+ : `Wishlist sync in progress (pass at offset ${offset}). Run untappd_sync_wishlist again to continue; removals apply when the pass finishes.`;
111
+ return {
112
+ username: rawUsername,
113
+ wishlist_added: added,
114
+ wishlist_removed: removed,
115
+ pages_fetched: pages,
116
+ cached,
117
+ total,
118
+ complete,
119
+ another_run_needed: !complete,
120
+ next_offset: complete ? null : offset,
121
+ stopped_for_rate_limit: rateStop !== undefined,
122
+ ...(rateStop ? { rate_limit: rateStop } : {}),
123
+ last_synced_at: now(),
124
+ note,
125
+ };
126
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
2
  import { mapCheckinRow } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
3
4
  const PAGE_LIMIT = 50; // Untappd's max page size for /user/checkins.
4
5
  // Backfill counts as complete only when the cache holds ~this fraction of the
5
6
  // API's reported total_checkins — allowing drift for deleted check-ins. Below
@@ -41,7 +42,10 @@ async function fetchTotalCheckins(client, encodedUser) {
41
42
  const t = info?.user?.stats?.total_checkins;
42
43
  return typeof t === 'number' ? t : null;
43
44
  }
44
- catch {
45
+ catch (e) {
46
+ // A 429 here means every later call would 429 too — let the caller stop.
47
+ if (e instanceof RateLimitError)
48
+ throw e;
45
49
  return null;
46
50
  }
47
51
  }
@@ -74,7 +78,42 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
74
78
  const encodedUser = encodeURIComponent(rawUsername);
75
79
  const now = () => new Date().toISOString();
76
80
  const priorState = await cache.getState(rawUsername);
77
- const total = await fetchTotalCheckins(client, encodedUser);
81
+ // The stop-before-429 guard, consulted before EVERY call. Once it (or a real
82
+ // 429) stops the run, nothing else is fetched; progress is persisted per page.
83
+ let rateStop;
84
+ const gate = async () => {
85
+ if (rateStop)
86
+ return false;
87
+ const v = await checkRateBudget(client, cache);
88
+ if (!v.ok)
89
+ rateStop = stopFromVerdict(v);
90
+ return v.ok;
91
+ };
92
+ /** Fetch a page, or return null (and record why) when rate limited. */
93
+ const guardedFetch = async (maxId) => {
94
+ if (!(await gate()))
95
+ return null;
96
+ try {
97
+ return await nextFetch(maxId);
98
+ }
99
+ catch (e) {
100
+ if (!(e instanceof RateLimitError))
101
+ throw e;
102
+ rateStop = stopFrom429(e);
103
+ return null;
104
+ }
105
+ };
106
+ let total = priorState?.total_checkins ?? null;
107
+ if (await gate()) {
108
+ try {
109
+ total = await fetchTotalCheckins(client, encodedUser);
110
+ }
111
+ catch (e) {
112
+ if (!(e instanceof RateLimitError))
113
+ throw e;
114
+ rateStop = stopFrom429(e);
115
+ }
116
+ }
78
117
  const cachedAtStart = await cache.cachedCount(rawUsername);
79
118
  // Self-heal a cache wrongly flagged complete (the historical bug): if state
80
119
  // claims done but coverage is well short of total, treat it like a forced reset.
@@ -176,7 +215,10 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
176
215
  let caughtUp = false;
177
216
  let phase1Pages = 0;
178
217
  for (; phase1Pages < phase1Budget;) {
179
- const page = await nextFetch(maxId);
218
+ const fetched = await guardedFetch(maxId);
219
+ if (fetched === null)
220
+ break; // rate limited: catch-up resumes next run
221
+ const page = fetched;
180
222
  pages++;
181
223
  phase1Pages++;
182
224
  if (page.items.length === 0) {
@@ -231,7 +273,10 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
231
273
  let maxId = oldestMaxId ?? undefined;
232
274
  let phase2Pages = 0;
233
275
  for (; phase2Pages < phase2Budget;) {
234
- const page = await nextFetch(maxId);
276
+ const fetched = await guardedFetch(maxId);
277
+ if (fetched === null)
278
+ break; // rate limited: backfill resumes from oldest_max_id next run
279
+ const page = fetched;
235
280
  pages++;
236
281
  phase2Pages++;
237
282
  if (page.items.length === 0) {
@@ -287,14 +332,16 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
287
332
  // Re-running user/checkins only helps while there is more of the OWN history to
288
333
  // page. A truncated (non-self) history can't advance that way — the caller
289
334
  // should switch to untappd_sync_user_beers for has-had coverage.
290
- const anotherRunNeeded = catchupInProgress || (!backfillComplete && !checkinsTruncated);
291
- const note = checkinsTruncated
292
- ? 'Untappd only returns the most recent check-ins for this account (history_truncated), so the full check-in history cannot be paged. Use untappd_sync_user_beers to cache the complete distinct-beers list for has-had coverage.'
293
- : catchupInProgress
294
- ? 'More new check-ins remain than fit in this run — run untappd_sync_checkins again to finish catching up.'
295
- : backfillComplete
296
- ? 'Full check-in history is cached. Re-run occasionally to pick up new check-ins.'
297
- : `Backfill incomplete — run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
335
+ const anotherRunNeeded = catchupInProgress || (!backfillComplete && !checkinsTruncated) || rateStop !== undefined;
336
+ const note = rateStop
337
+ ? `Check-in sync paused (${cached}${total !== null ? `/${total}` : ''} cached). ${resumeAdvice(rateStop, 'untappd_sync_checkins')}`
338
+ : checkinsTruncated
339
+ ? 'Untappd only returns the most recent check-ins for this account (history_truncated), so the full check-in history cannot be paged. Use untappd_sync_user_beers to cache the complete distinct-beers list for has-had coverage.'
340
+ : catchupInProgress
341
+ ? 'More new check-ins remain than fit in this run — run untappd_sync_checkins again to finish catching up.'
342
+ : backfillComplete
343
+ ? 'Full check-in history is cached. Re-run occasionally to pick up new check-ins.'
344
+ : `Backfill incomplete — run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
298
345
  return {
299
346
  username: rawUsername,
300
347
  rows_added: added,
@@ -308,6 +355,8 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
308
355
  history_truncated: checkinsTruncated,
309
356
  forced: doReset,
310
357
  another_run_needed: anotherRunNeeded,
358
+ stopped_for_rate_limit: rateStop !== undefined,
359
+ ...(rateStop ? { rate_limit: rateStop } : {}),
311
360
  last_synced_at: now(),
312
361
  note,
313
362
  };