untappd-mcp 2.1.2 → 2.2.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.
@@ -175,7 +175,9 @@ export const SCHEMA_STATEMENTS = [
175
175
  beers_offset INTEGER,
176
176
  beers_total INTEGER,
177
177
  beers_complete INTEGER NOT NULL DEFAULT 0,
178
- served_backfill_last INTEGER NOT NULL DEFAULT 0
178
+ served_backfill_last INTEGER NOT NULL DEFAULT 0,
179
+ beers_accepted_gap INTEGER NOT NULL DEFAULT 0,
180
+ beers_rescan_target INTEGER
179
181
  )`,
180
182
  ];
181
183
  /** Columns added after the initial release, each applied idempotently on open. */
@@ -186,6 +188,8 @@ export const MIGRATIONS = [
186
188
  'ALTER TABLE sync_state ADD COLUMN beers_total INTEGER',
187
189
  'ALTER TABLE sync_state ADD COLUMN beers_complete INTEGER NOT NULL DEFAULT 0',
188
190
  'ALTER TABLE sync_state ADD COLUMN served_backfill_last INTEGER NOT NULL DEFAULT 0',
191
+ 'ALTER TABLE sync_state ADD COLUMN beers_accepted_gap INTEGER NOT NULL DEFAULT 0',
192
+ 'ALTER TABLE sync_state ADD COLUMN beers_rescan_target INTEGER',
189
193
  ];
190
194
  /**
191
195
  * The cache logic over a synchronous {@link SqlDriver}. Usernames are keyed
@@ -298,6 +302,8 @@ export class CheckinStoreCore {
298
302
  beers_total: num(row.beers_total),
299
303
  beers_complete: Number(row.beers_complete) === 1,
300
304
  served_backfill_last: Number(row.served_backfill_last) === 1,
305
+ beers_accepted_gap: num(row.beers_accepted_gap) ?? 0,
306
+ beers_rescan_target: num(row.beers_rescan_target),
301
307
  };
302
308
  }
303
309
  /**
@@ -322,20 +328,24 @@ export class CheckinStoreCore {
322
328
  beers_total: pick('beers_total', null),
323
329
  beers_complete: pick('beers_complete', false),
324
330
  served_backfill_last: pick('served_backfill_last', false),
331
+ beers_accepted_gap: pick('beers_accepted_gap', 0),
332
+ beers_rescan_target: pick('beers_rescan_target', null),
325
333
  };
326
334
  this.db.run(`INSERT INTO sync_state
327
- (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last)
328
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
335
+ (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last, beers_accepted_gap, beers_rescan_target)
336
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
329
337
  ON CONFLICT(username) DO UPDATE SET
330
338
  oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
331
339
  catchup_max_id=excluded.catchup_max_id, last_synced_at=excluded.last_synced_at,
332
340
  backfill_complete=excluded.backfill_complete, total_checkins=excluded.total_checkins,
333
341
  checkins_truncated=excluded.checkins_truncated, beers_offset=excluded.beers_offset,
334
342
  beers_total=excluded.beers_total, beers_complete=excluded.beers_complete,
335
- served_backfill_last=excluded.served_backfill_last`, [
343
+ served_backfill_last=excluded.served_backfill_last, beers_accepted_gap=excluded.beers_accepted_gap,
344
+ beers_rescan_target=excluded.beers_rescan_target`, [
336
345
  key, next.oldest_max_id, next.newest_checkin_id, next.catchup_max_id, next.last_synced_at,
337
346
  next.backfill_complete ? 1 : 0, next.total_checkins, next.checkins_truncated ? 1 : 0,
338
347
  next.beers_offset, next.beers_total, next.beers_complete ? 1 : 0, next.served_backfill_last ? 1 : 0,
348
+ next.beers_accepted_gap, next.beers_rescan_target,
339
349
  ]);
340
350
  }
341
351
  /**
@@ -1,6 +1,10 @@
1
1
  import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
2
  import { mapBeerRow } from './store.js';
3
3
  const PAGE_LIMIT = 50; // Untappd's max page size for /user/beers.
4
+ // A resumed backfill re-reads this many already-fetched items: if beers dropped
5
+ // out of the list between runs, everything below shifted UP past the stored
6
+ // offset, and without the overlap the beers that crossed it would be skipped.
7
+ const RESUME_OVERLAP = 10;
4
8
  /** One `/user/beers` page fetch (offset-paged), tolerant of the fat shape. */
5
9
  async function fetchBeersPage(client, encodedUser, offset) {
6
10
  const data = await client.get(`/user/beers/${encodedUser}`, { limit: PAGE_LIMIT, offset, sort: 'date' });
@@ -35,8 +39,15 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
35
39
  // An already-complete cache re-scans from the top to pick up newly-had beers,
36
40
  // stopping on the first page with nothing new. Otherwise resume the backfill.
37
41
  const incremental = (prior?.beers_complete ?? false) && !!prior?.last_synced_at;
38
- let offset = incremental ? 0 : (prior?.beers_offset ?? 0);
42
+ // `refreshing` is the live mode: it starts as `incremental` and is cleared when
43
+ // a shortfall forces a rescan (which must page past "nothing new" pages).
44
+ let refreshing = incremental;
45
+ let offset = incremental ? 0 : Math.max(0, (prior?.beers_offset ?? 0) - RESUME_OVERLAP);
39
46
  let total = prior?.beers_total ?? null;
47
+ let acceptedGap = prior?.beers_accepted_gap ?? 0;
48
+ // Non-null while a coverage rescan is walking down from the top (possibly
49
+ // across runs): it stops as soon as the shortfall is back within this target.
50
+ let rescanTarget = incremental ? null : (prior?.beers_rescan_target ?? null);
40
51
  let complete = false;
41
52
  let pages = 0;
42
53
  let added = 0;
@@ -52,42 +63,60 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
52
63
  });
53
64
  }
54
65
  };
66
+ const gapNow = async () => total === null ? 0 : total - (await cache.distinctBeersCount(rawUsername));
55
67
  for (; pages < maxPages && !complete;) {
56
68
  const page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client, encodedUser, offset);
57
69
  pages++;
58
70
  if (page.total !== null)
59
71
  total = page.total;
60
- if (page.items.length === 0) {
61
- // An empty (e.g. first) page still means the list is fully paged — persist
62
- // completion so a subsequent call doesn't re-run the same empty fetch.
63
- complete = true;
64
- await cache.setState(rawUsername, {
65
- beers_offset: incremental ? (total ?? offset) : offset,
66
- beers_total: total,
67
- beers_complete: true,
68
- last_synced_at: now(),
69
- });
70
- break;
72
+ // An empty (e.g. first) page still means the list is fully paged.
73
+ let atEnd = page.items.length === 0;
74
+ if (!atEnd) {
75
+ const rows = rowsOf(rawUsername, page.items);
76
+ const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
77
+ added += netNew;
78
+ offset += page.items.length;
79
+ // 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)
82
+ atEnd = true;
83
+ else if (page.items.length < PAGE_LIMIT)
84
+ atEnd = true;
85
+ else if (refreshing && netNew === 0)
86
+ atEnd = true;
87
+ else if (rescanTarget !== null && (await gapNow()) <= rescanTarget)
88
+ atEnd = true;
89
+ }
90
+ if (atEnd) {
91
+ // Offset paging across runs is not a snapshot: when the list churns between
92
+ // runs (a beer drops out, or is re-sorted) a beer can slip behind the resumed
93
+ // offset and never be fetched. Reaching the end is therefore only "complete"
94
+ // if the cache actually holds total_count beers — less whatever shortfall a
95
+ // previous full pass already proved the list can never close (a count Untappd
96
+ // reports but never lists). A NEW shortfall means skipped beers: rescan from
97
+ // the top (upserts are idempotent) instead of reporting false coverage.
98
+ const gap = await gapNow();
99
+ if (gap > acceptedGap && rescanTarget === null) {
100
+ rescanTarget = acceptedGap;
101
+ acceptedGap = gap; // a rescan that finds nothing new accepts this gap
102
+ refreshing = false;
103
+ offset = 0;
104
+ }
105
+ else {
106
+ complete = true;
107
+ rescanTarget = null;
108
+ acceptedGap = Math.max(0, gap);
109
+ }
71
110
  }
72
- const rows = rowsOf(rawUsername, page.items);
73
- const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
74
- added += netNew;
75
- offset += page.items.length;
76
- // End conditions: caught the whole list, a short (final) page, or — during an
77
- // incremental top refresh — a page that added nothing new.
78
- if (total !== null && offset >= total)
79
- complete = true;
80
- else if (page.items.length < PAGE_LIMIT)
81
- complete = true;
82
- else if (incremental && netNew === 0)
83
- complete = true;
84
111
  // Persist progress after EVERY page. During an incremental refresh the stored
85
112
  // offset stays at the completed total so the next run refreshes from the top
86
113
  // again rather than resuming a stale cursor.
87
114
  await cache.setState(rawUsername, {
88
- beers_offset: incremental ? (total ?? offset) : offset,
115
+ beers_offset: refreshing ? (total ?? offset) : offset,
89
116
  beers_total: total,
90
117
  beers_complete: complete,
118
+ beers_accepted_gap: acceptedGap,
119
+ beers_rescan_target: rescanTarget,
91
120
  last_synced_at: now(),
92
121
  });
93
122
  }