untappd-mcp 2.3.1 → 2.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.
package/dist/cache/db.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, statSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { readEnvVar } from '@chrischall/mcp-utils';
6
6
  import { CheckinStoreCore, LocalCacheStore } from './store.js';
@@ -9,26 +9,45 @@ import { CheckinStoreCore, LocalCacheStore } from './store.js';
9
9
  // (src/cache/store.ts); this file only adapts `node:sqlite` to the SqlDriver
10
10
  // surface and manages the file handle. Another deployment can adapt a
11
11
  // different driver to the same surface.
12
+ // Upper bound on cached prepared statements per connection.
13
+ const STATEMENT_CACHE_MAX = 200;
12
14
  /** Adapts a `node:sqlite` DatabaseSync to the driver surface the core needs. */
13
15
  class NodeSqlDriver {
14
16
  db;
17
+ // Prepared statements, reused by SQL text: an upsert page runs the same
18
+ // INSERT 50 times. IN-lists make some SQL vary with the list length, so the
19
+ // cache is bounded and simply reset when full.
20
+ stmts = new Map();
15
21
  constructor(db) {
16
22
  this.db = db;
17
23
  }
24
+ prepared(sql) {
25
+ let st = this.stmts.get(sql);
26
+ if (!st) {
27
+ if (this.stmts.size >= STATEMENT_CACHE_MAX)
28
+ this.stmts.clear();
29
+ st = this.db.prepare(sql);
30
+ this.stmts.set(sql, st);
31
+ }
32
+ return st;
33
+ }
18
34
  execScript(sql) {
19
35
  this.db.exec(sql);
20
36
  }
21
37
  run(sql, params) {
22
- this.db.prepare(sql).run(...params);
38
+ this.prepared(sql).run(...params);
23
39
  }
24
40
  get(sql, params) {
25
- return this.db.prepare(sql).get(...params);
41
+ return this.prepared(sql).get(...params);
26
42
  }
27
43
  all(sql, params) {
28
- return this.db.prepare(sql).all(...params);
44
+ return this.prepared(sql).all(...params);
29
45
  }
30
46
  transaction(fn) {
31
- this.db.exec('BEGIN');
47
+ // IMMEDIATE takes the write lock up front. A DEFERRED transaction that
48
+ // reads and then writes can hit SQLITE_BUSY on the lock upgrade, which
49
+ // busy_timeout does NOT retry — the failure mode of two hosts open at once.
50
+ this.db.exec('BEGIN IMMEDIATE');
32
51
  try {
33
52
  fn();
34
53
  this.db.exec('COMMIT');
@@ -42,9 +61,13 @@ class NodeSqlDriver {
42
61
  /** A file-backed check-in cache. `open()` creates parent dirs and the schema. */
43
62
  export class CheckinCache extends LocalCacheStore {
44
63
  db;
45
- constructor(db) {
64
+ path;
65
+ constructor(db,
66
+ /** Absolute path of the backing file, or `:memory:`. */
67
+ path) {
46
68
  super(new CheckinStoreCore(new NodeSqlDriver(db)));
47
69
  this.db = db;
70
+ this.path = path;
48
71
  }
49
72
  /**
50
73
  * Open (creating parent dirs) a file-backed cache, or `:memory:` for tests.
@@ -54,11 +77,40 @@ export class CheckinCache extends LocalCacheStore {
54
77
  static open(path) {
55
78
  if (path !== ':memory:')
56
79
  preparePrivateFile(path);
57
- const cache = new CheckinCache(new DatabaseSync(path));
80
+ const db = new DatabaseSync(path);
81
+ // Set before the schema/migration transaction so even that waits out a
82
+ // second process instead of failing. WAL lets readers and one writer run
83
+ // concurrently (two hosts open at once); NORMAL sync is the WAL-safe default.
84
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
85
+ if (path !== ':memory:') {
86
+ db.exec('PRAGMA journal_mode = WAL');
87
+ db.exec('PRAGMA synchronous = NORMAL');
88
+ }
89
+ const cache = new CheckinCache(db, path);
58
90
  if (path !== ':memory:')
59
91
  tightenSidecars(path);
60
92
  return cache;
61
93
  }
94
+ /** The SQLite journal mode actually in effect (`wal` for a file, `memory` for :memory:). */
95
+ journalMode() {
96
+ const row = this.db.prepare('PRAGMA journal_mode').get();
97
+ return String(row?.journal_mode ?? 'unknown');
98
+ }
99
+ /** On-disk footprint: the db file plus its -wal/-shm side files. */
100
+ fileInfo() {
101
+ let bytes = 0;
102
+ if (this.path !== ':memory:') {
103
+ for (const suffix of ['', '-wal', '-shm']) {
104
+ try {
105
+ bytes += statSync(this.path + suffix).size;
106
+ }
107
+ catch {
108
+ /* side file not present */
109
+ }
110
+ }
111
+ }
112
+ return { path: this.path, bytes, journal_mode: this.journalMode() };
113
+ }
62
114
  close() {
63
115
  this.db.close();
64
116
  }
@@ -66,14 +118,19 @@ export class CheckinCache extends LocalCacheStore {
66
118
  /**
67
119
  * Create the cache's directory (0700 — `mode` applies to every dir mkdir
68
120
  * creates) and the db file itself (0600) BEFORE SQLite opens it, so the file is
69
- * never briefly umask-readable. Pre-existing loose entries from older versions
70
- * are tightened too; chmod failures (e.g. a shared dir the user doesn't own)
71
- * are best-effort, never fatal.
121
+ * never briefly umask-readable. The db file is always tightened, and so is the
122
+ * server's own default directory (`~/.untappd-mcp`, which an older version may
123
+ * have left loose). A directory the user pointed `UNTAPPD_CACHE_DB` into that
124
+ * already existed is left as it is: it may hold other files (a shared Dropbox
125
+ * folder), and making it owner-only would break access to them. chmod failures
126
+ * (e.g. a shared dir the user doesn't own) are best-effort, never fatal.
72
127
  */
73
128
  function preparePrivateFile(path) {
74
129
  const dir = dirname(path);
130
+ const dirExisted = existsSync(dir);
75
131
  mkdirSync(dir, { recursive: true, mode: 0o700 });
76
- bestEffortChmod(dir, 0o700);
132
+ if (!dirExisted || resolve(dir) === resolve(defaultCacheDir()))
133
+ bestEffortChmod(dir, 0o700);
77
134
  if (!existsSync(path))
78
135
  closeSync(openSync(path, 'a', 0o600));
79
136
  bestEffortChmod(path, 0o600);
@@ -93,10 +150,52 @@ function bestEffortChmod(path, mode) {
93
150
  /* best-effort — not every filesystem supports POSIX modes */
94
151
  }
95
152
  }
96
- /** Default on-disk cache path: `$UNTAPPD_CACHE_DB` or `~/.untappd-mcp/checkins.db`. */
153
+ // How long a write waits for another process's lock before failing.
154
+ const BUSY_TIMEOUT_MS = 5000;
155
+ /**
156
+ * The on-disk cache path, always ABSOLUTE: `$UNTAPPD_CACHE_DB` (a file), else
157
+ * `$UNTAPPD_CACHE_DIR/checkins.db`, else `~/.untappd-mcp/checkins.db`.
158
+ *
159
+ * It must not depend on how the host launched the server — Claude Desktop runs
160
+ * the .mcpb from its own extension dir, Claude Code from the project dir — so a
161
+ * relative override resolves against the HOME directory, never the working
162
+ * directory (which would silently give each host its own empty cache), and a
163
+ * leading `~` is expanded.
164
+ */
97
165
  export function defaultCachePath() {
98
- return readEnvVar('UNTAPPD_CACHE_DB') ?? join(homedir(), '.untappd-mcp', 'checkins.db');
166
+ const file = readEnvVar('UNTAPPD_CACHE_DB');
167
+ if (file)
168
+ return file === ':memory:' ? file : absolutise(file);
169
+ const dir = readEnvVar('UNTAPPD_CACHE_DIR');
170
+ return join(dir ? absolutise(dir) : defaultCacheDir(), 'checkins.db');
171
+ }
172
+ function absolutise(p) {
173
+ const expanded = p === '~' ? homedir() : p.startsWith('~/') ? join(homedir(), p.slice(2)) : p;
174
+ return isAbsolute(expanded) ? expanded : resolve(homedir(), expanded);
175
+ }
176
+ /** One stderr line naming the resolved cache file, for the startup log. */
177
+ export function describeCachePath(path) {
178
+ let state = 'not created yet';
179
+ try {
180
+ // Count the -wal too: after an unclean exit most recent writes live there.
181
+ let bytes = statSync(path).size;
182
+ try {
183
+ bytes += statSync(`${path}-wal`).size;
184
+ }
185
+ catch {
186
+ /* no WAL */
187
+ }
188
+ state = `exists, ${bytes} bytes`;
189
+ }
190
+ catch {
191
+ /* not created yet */
192
+ }
193
+ return `[untappd-mcp] cache: ${path} (${state})`;
99
194
  }
100
195
  // Re-export the shared types/helpers tests and tools import from here so their
101
196
  // existing import paths keep working.
102
197
  export { mapCheckinRow, } from './store.js';
198
+ /** The directory the server owns by default: `~/.untappd-mcp`. */
199
+ function defaultCacheDir() {
200
+ return join(homedir(), '.untappd-mcp');
201
+ }
@@ -180,7 +180,11 @@ export const SCHEMA_STATEMENTS = [
180
180
  beers_rescan_target INTEGER
181
181
  )`,
182
182
  ];
183
- /** Columns added after the initial release, each applied idempotently on open. */
183
+ /**
184
+ * Columns added to the v1 tables before the schema was versioned. A database
185
+ * at user_version 0 may predate any of them, so each is applied idempotently
186
+ * (ADD COLUMN throws harmlessly when the column already exists).
187
+ */
184
188
  export const MIGRATIONS = [
185
189
  'ALTER TABLE sync_state ADD COLUMN catchup_max_id INTEGER',
186
190
  'ALTER TABLE sync_state ADD COLUMN checkins_truncated INTEGER NOT NULL DEFAULT 0',
@@ -191,6 +195,35 @@ export const MIGRATIONS = [
191
195
  'ALTER TABLE sync_state ADD COLUMN beers_accepted_gap INTEGER NOT NULL DEFAULT 0',
192
196
  'ALTER TABLE sync_state ADD COLUMN beers_rescan_target INTEGER',
193
197
  ];
198
+ /**
199
+ * Forward-only, versioned migrations, tracked in SQLite's `PRAGMA user_version`.
200
+ * Version 1 is the baseline (SCHEMA_STATEMENTS + the legacy MIGRATIONS above);
201
+ * every later change is appended here with the next version number and is
202
+ * ADDITIVE — a migration never drops or rewrites user rows, so upgrading can't
203
+ * silently wipe a cache that took an hour of rate-limited calls to fill.
204
+ */
205
+ export const VERSIONED_MIGRATIONS = [
206
+ {
207
+ version: 2,
208
+ statements: [
209
+ `CREATE TABLE IF NOT EXISTS item_resolutions (
210
+ key TEXT PRIMARY KEY,
211
+ item TEXT NOT NULL,
212
+ status TEXT NOT NULL,
213
+ bid INTEGER,
214
+ data TEXT,
215
+ resolved_at TEXT NOT NULL
216
+ )`,
217
+ ],
218
+ },
219
+ {
220
+ // untappd_cache_query's default (and date-range) ordering.
221
+ version: 3,
222
+ statements: ['CREATE INDEX IF NOT EXISTS idx_checkins_user_created ON checkins(username, created_at)'],
223
+ },
224
+ ];
225
+ /** The schema version this code writes. */
226
+ export const SCHEMA_VERSION = VERSIONED_MIGRATIONS.reduce((v, m) => Math.max(v, m.version), 1);
194
227
  /**
195
228
  * The cache logic over a synchronous {@link SqlDriver}. Usernames are keyed
196
229
  * case-insensitively via a lowercased key so `Mer1331` and `mer1331` share one
@@ -198,20 +231,54 @@ export const MIGRATIONS = [
198
231
  */
199
232
  export class CheckinStoreCore {
200
233
  db;
234
+ version = 0;
201
235
  constructor(db) {
202
236
  this.db = db;
203
- for (const stmt of SCHEMA_STATEMENTS)
204
- this.db.execScript(stmt);
205
- // Forward-compat for a table created before a column existed; each ADD COLUMN
206
- // throws harmlessly if the column is already present.
207
- for (const mig of MIGRATIONS) {
208
- try {
209
- this.db.execScript(mig);
237
+ this.migrate();
238
+ }
239
+ /**
240
+ * Bring the schema up to {@link SCHEMA_VERSION}. Runs in ONE write
241
+ * transaction and re-reads the version inside it, so two processes opening a
242
+ * fresh file at once can't both apply the same step. A file written by a
243
+ * NEWER build is left untouched (its migrations are additive, so this build's
244
+ * queries still work) rather than "repaired" downwards.
245
+ */
246
+ migrate() {
247
+ this.db.transaction(() => {
248
+ const current = Number(this.db.get('PRAGMA user_version', [])?.user_version ?? 0);
249
+ if (current > SCHEMA_VERSION) {
250
+ console.error(`[untappd-mcp] cache schema v${current} is newer than this build (v${SCHEMA_VERSION}); using it as-is.`);
251
+ this.version = current;
252
+ return;
210
253
  }
211
- catch {
212
- /* column already exists */
254
+ if (current < 1) {
255
+ for (const stmt of SCHEMA_STATEMENTS)
256
+ this.db.execScript(stmt);
257
+ // A pre-versioning file may lack any of these columns; each ADD COLUMN
258
+ // throws harmlessly if the column is already present.
259
+ for (const mig of MIGRATIONS) {
260
+ try {
261
+ this.db.execScript(mig);
262
+ }
263
+ catch {
264
+ /* column already exists */
265
+ }
266
+ }
213
267
  }
214
- }
268
+ for (const m of VERSIONED_MIGRATIONS) {
269
+ if (m.version <= current)
270
+ continue;
271
+ for (const stmt of m.statements)
272
+ this.db.execScript(stmt);
273
+ }
274
+ if (current !== SCHEMA_VERSION)
275
+ this.db.execScript(`PRAGMA user_version = ${SCHEMA_VERSION}`);
276
+ this.version = SCHEMA_VERSION;
277
+ });
278
+ }
279
+ /** The schema version of the open database. */
280
+ schemaVersion() {
281
+ return this.version;
215
282
  }
216
283
  /**
217
284
  * Upsert real check-in rows (dedupe on checkin_id) in one transaction; return
@@ -221,7 +288,7 @@ export class CheckinStoreCore {
221
288
  if (rows.length === 0)
222
289
  return 0;
223
290
  const key = username.toLowerCase();
224
- const before = this.cachedCount(username);
291
+ let added = 0;
225
292
  const sql = `
226
293
  INSERT INTO checkins
227
294
  (checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
@@ -231,7 +298,14 @@ export class CheckinStoreCore {
231
298
  brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
232
299
  abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
233
300
  venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at`;
301
+ // "Net new" = ids this user didn't already have, read inside the write
302
+ // transaction so another process's concurrent inserts can't skew it. It
303
+ // looks up only this batch's ids; COUNT(*)-before/after scanned the user's
304
+ // whole history on every page, which made a long backfill quadratic.
234
305
  this.db.transaction(() => {
306
+ const ids = [...new Set(rows.map((r) => r.checkin_id))];
307
+ const had = this.existing('checkins', 'checkin_id', key, ids);
308
+ added = ids.filter((id) => !had.has(id)).length;
235
309
  for (const r of rows) {
236
310
  this.db.run(sql, [
237
311
  r.checkin_id, key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.beer_style,
@@ -239,14 +313,14 @@ export class CheckinStoreCore {
239
313
  ]);
240
314
  }
241
315
  });
242
- return this.cachedCount(username) - before;
316
+ return added;
243
317
  }
244
318
  /** Upsert distinct-beer rows (dedupe on username+bid); return the NET new count. */
245
319
  upsertDistinctBeers(username, rows) {
246
320
  if (rows.length === 0)
247
321
  return 0;
248
322
  const key = username.toLowerCase();
249
- const before = this.distinctBeersCount(username);
323
+ let added = 0;
250
324
  const sql = `
251
325
  INSERT INTO distinct_beers
252
326
  (username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, had_count, first_had, last_had)
@@ -256,6 +330,9 @@ export class CheckinStoreCore {
256
330
  beer_style=excluded.beer_style, abv=excluded.abv, rating=excluded.rating,
257
331
  had_count=excluded.had_count, first_had=excluded.first_had, last_had=excluded.last_had`;
258
332
  this.db.transaction(() => {
333
+ const bids = [...new Set(rows.map((r) => r.bid))];
334
+ const had = this.existing('distinct_beers', 'bid', key, bids);
335
+ added = bids.filter((b) => !had.has(b)).length;
259
336
  for (const r of rows) {
260
337
  this.db.run(sql, [
261
338
  key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.beer_style, r.abv,
@@ -263,7 +340,17 @@ export class CheckinStoreCore {
263
340
  ]);
264
341
  }
265
342
  });
266
- return this.distinctBeersCount(username) - before;
343
+ return added;
344
+ }
345
+ /** Which of `ids` already exist in `table.column` for this (lowercased) user. */
346
+ existing(table, column, key, ids) {
347
+ const found = new Set();
348
+ for (const part of chunks(ids)) {
349
+ const rows = this.db.all(`SELECT ${column} AS id FROM ${table} WHERE username = ? AND ${column} IN (${part.map(() => '?').join(', ')})`, [key, ...part]);
350
+ for (const r of rows)
351
+ found.add(Number(r.id));
352
+ }
353
+ return found;
267
354
  }
268
355
  /** Total cached check-in rows for a user. */
269
356
  cachedCount(username) {
@@ -312,6 +399,11 @@ export class CheckinStoreCore {
312
399
  * merge could not do that.
313
400
  */
314
401
  setState(username, patch) {
402
+ // Read-merge-write in one write transaction, so a second process's patch
403
+ // can't land between the read and the write and be silently reverted.
404
+ this.db.transaction(() => this.mergeState(username, patch));
405
+ }
406
+ mergeState(username, patch) {
315
407
  const key = username.toLowerCase();
316
408
  const cur = this.getState(key);
317
409
  const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
@@ -421,6 +513,46 @@ export class CheckinStoreCore {
421
513
  matches,
422
514
  };
423
515
  }
516
+ /**
517
+ * {@link hasHad} for many bids at once, in two grouped queries per chunk
518
+ * instead of ~4 per bid. Same merge rules: a hit in either source counts,
519
+ * `count` prefers user/beers' authoritative had_count, dates and ratings take
520
+ * the max across both. No per-check-in `matches` — callers of the batch form
521
+ * only want the verdict. Every requested bid gets an entry.
522
+ */
523
+ hasHadMany(username, bids) {
524
+ const key = username.toLowerCase();
525
+ const out = new Map();
526
+ const unique = [...new Set(bids)];
527
+ for (const bid of unique)
528
+ out.set(bid, { had: false, count: 0, last_date: null, best_rating: null, sources: [] });
529
+ for (const part of chunks(unique)) {
530
+ const ph = part.map(() => '?').join(', ');
531
+ for (const r of this.db.all(`SELECT bid, COUNT(*) AS c, MAX(rating) AS best, MAX(created_at) AS last
532
+ FROM checkins WHERE username = ? AND bid IN (${ph}) GROUP BY bid`, [key, ...part])) {
533
+ const v = out.get(Number(r.bid));
534
+ v.had = true;
535
+ v.count = Number(r.c);
536
+ v.best_rating = num(r.best);
537
+ v.last_date = str(r.last);
538
+ v.sources.push('checkins');
539
+ }
540
+ for (const r of this.db.all(`SELECT bid, had_count, rating, last_had, first_had FROM distinct_beers WHERE username = ? AND bid IN (${ph})`, [key, ...part])) {
541
+ const v = out.get(Number(r.bid));
542
+ v.had = true;
543
+ // Prefer the user/beers count; else keep the check-in-derived count; else 1.
544
+ v.count = num(r.had_count) ?? (v.count || 1);
545
+ const rating = num(r.rating);
546
+ if (rating !== null && (v.best_rating === null || rating > v.best_rating))
547
+ v.best_rating = rating;
548
+ const last = str(r.last_had) ?? str(r.first_had);
549
+ if (last && (v.last_date === null || last > v.last_date))
550
+ v.last_date = last;
551
+ v.sources.push('beers');
552
+ }
553
+ }
554
+ return out;
555
+ }
424
556
  query(username, filters) {
425
557
  const where = ['username = ?'];
426
558
  const params = [username.toLowerCase()];
@@ -482,6 +614,59 @@ export class CheckinStoreCore {
482
614
  });
483
615
  return removed;
484
616
  }
617
+ getDistinctBeers(username, bids) {
618
+ if (bids.length === 0)
619
+ return [];
620
+ const placeholders = bids.map(() => '?').join(', ');
621
+ return this.db.all(`SELECT * FROM distinct_beers WHERE username = ? AND bid IN (${placeholders})`, [username.toLowerCase(), ...bids]);
622
+ }
623
+ getResolutions(keys) {
624
+ if (keys.length === 0)
625
+ return [];
626
+ const placeholders = keys.map(() => '?').join(', ');
627
+ return this.db.all(`SELECT * FROM item_resolutions WHERE key IN (${placeholders})`, keys);
628
+ }
629
+ upsertResolutions(rows) {
630
+ if (rows.length === 0)
631
+ return;
632
+ const sql = `
633
+ INSERT INTO item_resolutions (key, item, status, bid, data, resolved_at)
634
+ VALUES (?, ?, ?, ?, ?, ?)
635
+ ON CONFLICT(key) DO UPDATE SET
636
+ item=excluded.item, status=excluded.status, bid=excluded.bid,
637
+ data=excluded.data, resolved_at=excluded.resolved_at`;
638
+ this.db.transaction(() => {
639
+ for (const r of rows)
640
+ this.db.run(sql, [r.key, r.item, r.status, r.bid, r.data, r.resolved_at]);
641
+ });
642
+ }
643
+ stats() {
644
+ const n = (sql) => Number(this.db.get(sql, [])?.n ?? 0);
645
+ const names = this.db
646
+ .all(`SELECT username FROM sync_state
647
+ UNION SELECT DISTINCT username FROM distinct_beers
648
+ UNION SELECT DISTINCT username FROM checkins
649
+ ORDER BY username`, [])
650
+ .map((r) => String(r.username));
651
+ const users = names.map((username) => {
652
+ const st = this.getState(username);
653
+ return {
654
+ username,
655
+ cached_distinct_beers: this.distinctBeersCount(username),
656
+ total_distinct_beers: st?.beers_total ?? null,
657
+ cached_checkins: this.cachedCount(username),
658
+ coverage_complete: (st?.beers_complete ?? false) || (st?.backfill_complete ?? false),
659
+ beers_offset: st?.beers_offset ?? null,
660
+ last_synced_at: st?.last_synced_at ?? null,
661
+ };
662
+ });
663
+ return {
664
+ schema_version: this.version,
665
+ users,
666
+ resolution_cache_entries: n('SELECT COUNT(*) AS n FROM item_resolutions'),
667
+ beer_meta_entries: n('SELECT COUNT(*) AS n FROM beer_meta'),
668
+ };
669
+ }
485
670
  getBeerMeta(bids) {
486
671
  if (bids.length === 0)
487
672
  return [];
@@ -547,6 +732,9 @@ export class LocalCacheStore {
547
732
  async hasHad(username, opts) {
548
733
  return this.core.hasHad(username, opts);
549
734
  }
735
+ async hasHadMany(username, bids) {
736
+ return this.core.hasHadMany(username, bids);
737
+ }
550
738
  async query(username, filters) {
551
739
  return this.core.query(username, filters);
552
740
  }
@@ -559,6 +747,27 @@ export class LocalCacheStore {
559
747
  async forgetUser(username) {
560
748
  return this.core.forgetUser(username);
561
749
  }
750
+ async getDistinctBeers(username, bids) {
751
+ return this.core.getDistinctBeers(username, bids);
752
+ }
753
+ async getResolutions(keys) {
754
+ return this.core.getResolutions(keys);
755
+ }
756
+ async upsertResolutions(rows) {
757
+ this.core.upsertResolutions(rows);
758
+ }
759
+ async stats() {
760
+ return this.core.stats();
761
+ }
762
+ }
763
+ // SQLite caps bound parameters per statement (999 on older builds); IN-lists
764
+ // are split well under that.
765
+ const IN_CHUNK = 500;
766
+ function chunks(xs) {
767
+ const out = [];
768
+ for (let i = 0; i < xs.length; i += IN_CHUNK)
769
+ out.push(xs.slice(i, i + IN_CHUNK));
770
+ return out;
562
771
  }
563
772
  // Case-insensitive substring match whose pattern is built by escapeLike(): the
564
773
  // ESCAPE clause makes a backslash-prefixed %, _ or backslash match itself literally.
@@ -51,6 +51,7 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
51
51
  let complete = false;
52
52
  let pages = 0;
53
53
  let added = 0;
54
+ let stoppedAtBid = null;
54
55
  const firstFetch = async (o) => {
55
56
  try {
56
57
  return await fetchBeersPage(client, encodedUser, o);
@@ -72,17 +73,37 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
72
73
  // An empty (e.g. first) page still means the list is fully paged.
73
74
  let atEnd = page.items.length === 0;
74
75
  if (!atEnd) {
75
- const rows = rowsOf(rawUsername, page.items);
76
+ let rows = rowsOf(rawUsername, page.items);
77
+ // Incremental refresh of a complete cache: the list is sorted by each
78
+ // beer's MOST RECENT check-in, so the first item whose cached copy is
79
+ // unchanged (same last-had date and count) marks where this refresh
80
+ // meets the previous one — everything below it was last had earlier and
81
+ // is already cached. Stop there instead of paging the whole list. A beer
82
+ // that is cached but CHANGED (re-had since) does not stop the walk: a
83
+ // brand-new beer can sit below it.
84
+ let hitKnown = false;
85
+ if (refreshing) {
86
+ const known = new Map((await cache.getDistinctBeers(rawUsername, rows.map((r) => r.bid))).map((r) => [r.bid, r]));
87
+ const stop = rows.findIndex((r) => {
88
+ const k = known.get(r.bid);
89
+ return k !== undefined && k.last_had === r.last_had && k.had_count === r.had_count;
90
+ });
91
+ if (stop !== -1) {
92
+ hitKnown = true;
93
+ stoppedAtBid = rows[stop].bid;
94
+ rows = rows.slice(0, stop);
95
+ }
96
+ }
76
97
  const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
77
98
  added += netNew;
78
99
  offset += page.items.length;
79
100
  // 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)
101
+ // incremental top refresh — reaching an unchanged cached beer.
102
+ if (hitKnown)
82
103
  atEnd = true;
83
- else if (page.items.length < PAGE_LIMIT)
104
+ else if (total !== null && offset >= total)
84
105
  atEnd = true;
85
- else if (refreshing && netNew === 0)
106
+ else if (page.items.length < PAGE_LIMIT)
86
107
  atEnd = true;
87
108
  else if (rescanTarget !== null && (await gapNow()) <= rescanTarget)
88
109
  atEnd = true;
@@ -135,6 +156,7 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
135
156
  backfill_percent: percent,
136
157
  beers_complete: complete,
137
158
  incremental,
159
+ stopped_at_bid: stoppedAtBid,
138
160
  another_run_needed: anotherRunNeeded,
139
161
  last_synced_at: now(),
140
162
  note,
package/dist/client.js CHANGED
@@ -13,6 +13,8 @@ if (import.meta.url) {
13
13
  const BASE_URL = 'https://api.untappd.com/v4';
14
14
  const SERVICE = 'Untappd';
15
15
  const REQUEST_TIMEOUT_MS = 30_000;
16
+ // Untappd's limit is per hour, so a remaining-count older than that says nothing.
17
+ const QUOTA_WINDOW_MS = 60 * 60 * 1000;
16
18
  // Non-secret client constants that mimic the Untappd iPad app (v4.7.13). The
17
19
  // API accepts requests carrying these; NONE are user secrets (the secrets are
18
20
  // UNTAPPD_CLIENT_ID / UNTAPPD_CLIENT_SECRET / UNTAPPD_USERNAME / _PASSWORD,
@@ -151,6 +153,8 @@ export class UntappdClient {
151
153
  userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
152
154
  token;
153
155
  loginInFlight = null;
156
+ /** Last `X-Ratelimit-Remaining` seen, and when (see {@link quotaRemaining}). */
157
+ lastQuota = null;
154
158
  tokenIsSupplied;
155
159
  /**
156
160
  * Defer the config error so the server can still start (and respond to the
@@ -206,6 +210,25 @@ export class UntappdClient {
206
210
  get loginName() {
207
211
  return this._loginName;
208
212
  }
213
+ /**
214
+ * Calls left this hour, from the most recent response's
215
+ * `X-Ratelimit-Remaining` — or null when no response has carried the header
216
+ * within the hour. Untappd's public API documents the header; whether the
217
+ * private v4 endpoints send it is unverified, so every consumer must treat
218
+ * null as "unknown" and fall back to its own budget.
219
+ */
220
+ get quotaRemaining() {
221
+ if (!this.lastQuota || Date.now() - this.lastQuota.at > QUOTA_WINDOW_MS)
222
+ return null;
223
+ return this.lastQuota.remaining;
224
+ }
225
+ noteQuota(res) {
226
+ const raw = res.headers.get('x-ratelimit-remaining');
227
+ const n = raw === null ? NaN : Number(raw);
228
+ if (raw !== null && raw.trim() !== '' && Number.isFinite(n) && n >= 0) {
229
+ this.lastQuota = { remaining: Math.floor(n), at: Date.now() };
230
+ }
231
+ }
209
232
  /** App creds for a write; reached only once a token is in hand. */
210
233
  requireAppCreds() {
211
234
  if (!this.clientId || !this.clientSecret) {
@@ -315,6 +338,7 @@ export class UntappdClient {
315
338
  body = f.toString();
316
339
  }
317
340
  const res = await this.send(method, `${BASE_URL}${path}${qs}`, { headers, body });
341
+ this.noteQuota(res);
318
342
  // A 401 means the token went stale. Re-minting one needs a login, and a
319
343
  // deployment that SUPPLIED the token may have no username/password to log
320
344
  // in with — that fell through to `missingCredsError()` and told the
package/dist/index.js CHANGED
@@ -14,16 +14,21 @@ import { registerWishlistTools } from './tools/wishlist.js';
14
14
  import { registerCheckinTools } from './tools/checkin.js';
15
15
  import { registerUtilityTools } from './tools/utilities.js';
16
16
  import { registerCacheTools } from './tools/cache.js';
17
+ import { registerMenuTools } from './tools/menu.js';
17
18
  import { logRegisteredTools } from './tools/diagnostics.js';
18
- import { CheckinCache, defaultCachePath } from './cache/db.js';
19
+ import { CheckinCache, defaultCachePath, describeCachePath } from './cache/db.js';
19
20
  // Build the env-based client once and inject it into each registrar. The
20
21
  // constructor defers its config error, so the server still boots (and answers
21
22
  // the host's install-time tools/list probe) when credentials are absent.
22
23
  const client = new UntappdClient();
23
24
  // The stdio server backs the cache with a local `node:sqlite` file, opened lazily
24
25
  // on first cache-tool use so the server still boots when no path is configured.
26
+ // The path is resolved (and logged) once at startup, so it can't drift with the
27
+ // working directory a host happens to launch from.
28
+ const cachePath = defaultCachePath();
29
+ console.error(describeCachePath(cachePath));
25
30
  let nodeCache;
26
- const nodeCacheProvider = () => (nodeCache ??= CheckinCache.open(defaultCachePath()));
31
+ const nodeCacheProvider = () => (nodeCache ??= CheckinCache.open(cachePath));
27
32
  await runMcp({
28
33
  name: 'untappd-mcp',
29
34
  version: VERSION,
@@ -43,6 +48,7 @@ await runMcp({
43
48
  // The cache is backed by a local `node:sqlite` file here; the provider is
44
49
  // injectable, so another deployment can back the same tools differently.
45
50
  (s) => registerCacheTools(s, client, nodeCacheProvider),
51
+ (s) => registerMenuTools(s, client, nodeCacheProvider),
46
52
  // Keep last: logs the full registered toolset (count + names) at startup.
47
53
  (s) => logRegisteredTools(s, 'stdio'),
48
54
  ],