untappd-mcp 2.3.2 → 2.5.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.
@@ -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
@@ -51,6 +52,7 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
51
52
  let complete = false;
52
53
  let pages = 0;
53
54
  let added = 0;
55
+ let stoppedAtBid = null;
54
56
  const firstFetch = async (o) => {
55
57
  try {
56
58
  return await fetchBeersPage(client, encodedUser, o);
@@ -64,25 +66,62 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
64
66
  }
65
67
  };
66
68
  const gapNow = async () => total === null ? 0 : total - (await cache.distinctBeersCount(rawUsername));
69
+ let rateStop;
67
70
  for (; pages < maxPages && !complete;) {
68
- 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
+ }
69
88
  pages++;
70
89
  if (page.total !== null)
71
90
  total = page.total;
72
91
  // An empty (e.g. first) page still means the list is fully paged.
73
92
  let atEnd = page.items.length === 0;
74
93
  if (!atEnd) {
75
- const rows = rowsOf(rawUsername, page.items);
94
+ let rows = rowsOf(rawUsername, page.items);
95
+ // Incremental refresh of a complete cache: the list is sorted by each
96
+ // beer's MOST RECENT check-in, so the first item whose cached copy is
97
+ // unchanged (same last-had date and count) marks where this refresh
98
+ // meets the previous one — everything below it was last had earlier and
99
+ // is already cached. Stop there instead of paging the whole list. A beer
100
+ // that is cached but CHANGED (re-had since) does not stop the walk: a
101
+ // brand-new beer can sit below it.
102
+ let hitKnown = false;
103
+ if (refreshing) {
104
+ const known = new Map((await cache.getDistinctBeers(rawUsername, rows.map((r) => r.bid))).map((r) => [r.bid, r]));
105
+ const stop = rows.findIndex((r) => {
106
+ const k = known.get(r.bid);
107
+ return k !== undefined && k.last_had === r.last_had && k.had_count === r.had_count;
108
+ });
109
+ if (stop !== -1) {
110
+ hitKnown = true;
111
+ stoppedAtBid = rows[stop].bid;
112
+ rows = rows.slice(0, stop);
113
+ }
114
+ }
76
115
  const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
77
116
  added += netNew;
78
117
  offset += page.items.length;
79
118
  // End conditions: caught the whole list, a short (final) page, or — during an
80
- // incremental top refresh — a page that added nothing new.
81
- if (total !== null && offset >= total)
119
+ // incremental top refresh — reaching an unchanged cached beer.
120
+ if (hitKnown)
82
121
  atEnd = true;
83
- else if (page.items.length < PAGE_LIMIT)
122
+ else if (total !== null && offset >= total)
84
123
  atEnd = true;
85
- else if (refreshing && netNew === 0)
124
+ else if (page.items.length < PAGE_LIMIT)
86
125
  atEnd = true;
87
126
  else if (rescanTarget !== null && (await gapNow()) <= rescanTarget)
88
127
  atEnd = true;
@@ -125,7 +164,9 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
125
164
  const anotherRunNeeded = !complete;
126
165
  const note = complete
127
166
  ? 'Full distinct-beers list is cached — has-had coverage is complete for this user. Re-run occasionally to pick up newly-had beers.'
128
- : `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.`;
129
170
  return {
130
171
  username: rawUsername,
131
172
  beers_added: added,
@@ -135,7 +176,10 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
135
176
  backfill_percent: percent,
136
177
  beers_complete: complete,
137
178
  incremental,
179
+ stopped_at_bid: stoppedAtBid,
138
180
  another_run_needed: anotherRunNeeded,
181
+ stopped_for_rate_limit: rateStop !== undefined,
182
+ ...(rateStop ? { rate_limit: rateStop } : {}),
139
183
  last_synced_at: now(),
140
184
  note,
141
185
  };
@@ -0,0 +1,124 @@
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
+ complete =
87
+ items.length === 0 || (total !== null ? advanced >= total : items.length < BREWERY_LIKES_PAGE_REQUEST);
88
+ offset = advanced;
89
+ if (complete)
90
+ removed = await cache.pruneBreweryLikes(rawUsername, passStamp);
91
+ await cache.setListState(rawUsername, BREWERY_LIKES_LIST, {
92
+ next_offset: complete ? null : offset,
93
+ total_count: total,
94
+ complete,
95
+ last_synced_at: now(),
96
+ });
97
+ if (complete)
98
+ await cache.setListState(rawUsername, BREWERY_LIKES_PASS_LIST, { complete: true });
99
+ }
100
+ const cached = await cache.breweryLikesCount(rawUsername);
101
+ const percent = total !== null && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null;
102
+ const progress = `${cached}${total !== null ? `/${total}` : ''}`;
103
+ const note = complete
104
+ ? `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.`
105
+ : rateStop
106
+ ? `Brewery-likes sync paused (pass at offset ${offset}). ${resumeAdvice(rateStop, 'untappd_sync_brewery_likes')}`
107
+ : `Brewery-likes sync in progress (pass at offset ${offset}). Run untappd_sync_brewery_likes again to continue; unlikes apply when the pass finishes.`;
108
+ return {
109
+ username: rawUsername,
110
+ likes_added: added,
111
+ likes_removed: removed,
112
+ pages_fetched: pages,
113
+ cached,
114
+ total,
115
+ percent,
116
+ complete,
117
+ another_run_needed: !complete,
118
+ next_offset: complete ? null : offset,
119
+ stopped_for_rate_limit: rateStop !== undefined,
120
+ ...(rateStop ? { rate_limit: rateStop } : {}),
121
+ last_synced_at: now(),
122
+ note,
123
+ };
124
+ }
@@ -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
+ }