untappd-mcp 2.3.2 → 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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +112 -4
- package/dist/bundle.js +1186 -133
- package/dist/cache/db.js +100 -10
- package/dist/cache/store.js +224 -15
- package/dist/cache/sync-beers.js +27 -5
- package/dist/client.js +24 -0
- package/dist/index.js +8 -2
- package/dist/menu/match.js +178 -0
- package/dist/menu/pick.js +514 -0
- package/dist/menu/venue-menu.js +108 -0
- package/dist/quota.js +14 -0
- package/dist/tools/beer.js +4 -1
- package/dist/tools/cache.js +54 -32
- package/dist/tools/menu.js +104 -0
- package/dist/tools/venue.js +14 -92
- package/dist/version.js +1 -1
- package/mint.yaml +12 -0
- package/package.json +5 -1
- package/server.json +9 -2
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, resolve } 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.
|
|
38
|
+
this.prepared(sql).run(...params);
|
|
23
39
|
}
|
|
24
40
|
get(sql, params) {
|
|
25
|
-
return this.
|
|
41
|
+
return this.prepared(sql).get(...params);
|
|
26
42
|
}
|
|
27
43
|
all(sql, params) {
|
|
28
|
-
return this.
|
|
44
|
+
return this.prepared(sql).all(...params);
|
|
29
45
|
}
|
|
30
46
|
transaction(fn) {
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -98,9 +150,47 @@ function bestEffortChmod(path, mode) {
|
|
|
98
150
|
/* best-effort — not every filesystem supports POSIX modes */
|
|
99
151
|
}
|
|
100
152
|
}
|
|
101
|
-
|
|
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
|
+
*/
|
|
102
165
|
export function defaultCachePath() {
|
|
103
|
-
|
|
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})`;
|
|
104
194
|
}
|
|
105
195
|
// Re-export the shared types/helpers tests and tools import from here so their
|
|
106
196
|
// existing import paths keep working.
|
package/dist/cache/store.js
CHANGED
|
@@ -180,7 +180,11 @@ export const SCHEMA_STATEMENTS = [
|
|
|
180
180
|
beers_rescan_target INTEGER
|
|
181
181
|
)`,
|
|
182
182
|
];
|
|
183
|
-
/**
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
package/dist/cache/sync-beers.js
CHANGED
|
@@ -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
|
-
|
|
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 —
|
|
81
|
-
if (
|
|
101
|
+
// incremental top refresh — reaching an unchanged cached beer.
|
|
102
|
+
if (hitKnown)
|
|
82
103
|
atEnd = true;
|
|
83
|
-
else if (
|
|
104
|
+
else if (total !== null && offset >= total)
|
|
84
105
|
atEnd = true;
|
|
85
|
-
else if (
|
|
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(
|
|
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
|
],
|