untappd-mcp 1.3.1 → 1.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
@@ -3,291 +3,63 @@ import { mkdirSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { readEnvVar } from '@chrischall/mcp-utils';
6
- function asDict(v) {
7
- return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
8
- }
9
- function num(v) {
10
- return typeof v === 'number' && Number.isFinite(v) ? v : null;
11
- }
12
- function str(v) {
13
- return typeof v === 'string' && v.length > 0 ? v : null;
14
- }
15
- /**
16
- * Normalise a raw `/user/checkins` item to a {@link CheckinRow}. Reads only the
17
- * documented list fields (the same ones `compactCheckin` uses) and derives
18
- * nothing it can't. `created_at` is normalised to ISO-8601 UTC so it sorts
19
- * lexicographically = chronologically and date-range filters are plain string
20
- * comparisons; the raw RFC-2822 string is kept if it can't be parsed.
21
- */
22
- export function mapCheckinRow(username, item) {
23
- const c = asDict(item);
24
- const checkin_id = num(c.checkin_id);
25
- if (checkin_id === null)
26
- return null; // can't key it — skip rather than corrupt the table
27
- const beer = asDict(c.beer);
28
- const brewery = asDict(c.brewery);
29
- const venue = asDict(c.venue); // Untappd sends `venue: []` when none — asDict → {}
30
- return {
31
- checkin_id,
32
- username,
33
- bid: num(beer.bid),
34
- beer_name: str(beer.beer_name),
35
- brewery_id: num(brewery.brewery_id),
36
- brewery_name: str(brewery.brewery_name),
37
- beer_style: str(beer.beer_style),
38
- abv: num(beer.beer_abv),
39
- rating: num(c.rating_score),
40
- comment: str(c.checkin_comment),
41
- venue_id: num(venue.venue_id),
42
- venue_name: str(venue.venue_name),
43
- created_at: normaliseDate(c.created_at),
44
- };
45
- }
46
- function normaliseDate(v) {
47
- const s = str(v);
48
- if (!s)
49
- return null;
50
- const t = Date.parse(s);
51
- return Number.isNaN(t) ? s : new Date(t).toISOString();
52
- }
53
- const SCHEMA = `
54
- CREATE TABLE IF NOT EXISTS checkins (
55
- checkin_id INTEGER PRIMARY KEY,
56
- username TEXT NOT NULL,
57
- bid INTEGER,
58
- beer_name TEXT,
59
- brewery_id INTEGER,
60
- brewery_name TEXT,
61
- beer_style TEXT,
62
- abv REAL,
63
- rating REAL,
64
- comment TEXT,
65
- venue_id INTEGER,
66
- venue_name TEXT,
67
- created_at TEXT
68
- );
69
- CREATE INDEX IF NOT EXISTS idx_checkins_user_bid ON checkins(username, bid);
70
- CREATE INDEX IF NOT EXISTS idx_checkins_user_brewery ON checkins(username, brewery_id);
71
-
72
- CREATE TABLE IF NOT EXISTS sync_state (
73
- username TEXT PRIMARY KEY,
74
- oldest_max_id INTEGER,
75
- newest_checkin_id INTEGER,
76
- catchup_max_id INTEGER,
77
- last_synced_at TEXT,
78
- backfill_complete INTEGER NOT NULL DEFAULT 0,
79
- total_checkins INTEGER
80
- );
81
- `;
82
- /**
83
- * Wraps a single SQLite database file. Usernames are compared
84
- * case-insensitively via a lowercased key so `Mer1331` and `mer1331` share one
85
- * cache; the original casing the caller passed is preserved in stored rows.
86
- */
87
- export class CheckinCache {
6
+ import { CheckinStoreCore, LocalCacheStore } from './store.js';
7
+ // The `node:sqlite` backend for the check-in cache — a local on-disk SQLite file
8
+ // used by the stdio/desktop server. The query logic lives in CheckinStoreCore
9
+ // (src/cache/store.ts); this file only adapts `node:sqlite` to the SqlDriver
10
+ // surface and manages the file handle. (The remote Cloudflare connector uses a
11
+ // Durable Object backend instead — see src/cache/durable.ts.)
12
+ /** Adapts a `node:sqlite` DatabaseSync to the driver surface the core needs. */
13
+ class NodeSqlDriver {
88
14
  db;
89
15
  constructor(db) {
90
16
  this.db = db;
91
- this.db.exec(SCHEMA);
92
- this.migrate();
93
17
  }
94
- // Forward-compat for a sync_state table created before a column existed. Each
95
- // ADD COLUMN throws harmlessly if the column is already present.
96
- migrate() {
97
- for (const col of ['catchup_max_id INTEGER']) {
98
- try {
99
- this.db.exec(`ALTER TABLE sync_state ADD COLUMN ${col}`);
100
- }
101
- catch {
102
- /* column already exists */
103
- }
104
- }
18
+ execScript(sql) {
19
+ this.db.exec(sql);
105
20
  }
106
- /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
107
- static open(path) {
108
- if (path !== ':memory:')
109
- mkdirSync(dirname(path), { recursive: true });
110
- return new CheckinCache(new DatabaseSync(path));
21
+ run(sql, params) {
22
+ this.db.prepare(sql).run(...params);
111
23
  }
112
- close() {
113
- this.db.close();
24
+ get(sql, params) {
25
+ return this.db.prepare(sql).get(...params);
114
26
  }
115
- /**
116
- * Upsert a batch of rows (dedupe on checkin_id) inside one transaction and
117
- * return the NET number of newly-inserted rows (updates to existing rows count
118
- * as 0), which is what "rows added" reports.
119
- */
120
- upsertCheckins(username, rows) {
121
- if (rows.length === 0)
122
- return 0;
123
- const key = username.toLowerCase();
124
- const before = this.cachedCount(username);
125
- const stmt = this.db.prepare(`
126
- INSERT INTO checkins
127
- (checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
128
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
129
- ON CONFLICT(checkin_id) DO UPDATE SET
130
- username=excluded.username, bid=excluded.bid, beer_name=excluded.beer_name,
131
- brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
132
- abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
133
- venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at
134
- `);
27
+ all(sql, params) {
28
+ return this.db.prepare(sql).all(...params);
29
+ }
30
+ transaction(fn) {
135
31
  this.db.exec('BEGIN');
136
32
  try {
137
- for (const r of rows) {
138
- stmt.run(r.checkin_id, key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.beer_style, r.abv, r.rating, r.comment, r.venue_id, r.venue_name, r.created_at);
139
- }
33
+ fn();
140
34
  this.db.exec('COMMIT');
141
35
  }
142
36
  catch (e) {
143
37
  this.db.exec('ROLLBACK');
144
38
  throw e;
145
39
  }
146
- return this.cachedCount(username) - before;
147
- }
148
- /** Total cached check-ins for a user (= distinct check-ins, since PK is checkin_id). */
149
- cachedCount(username) {
150
- const row = this.db
151
- .prepare('SELECT COUNT(*) AS n FROM checkins WHERE username = ?')
152
- .get(username.toLowerCase());
153
- return row.n;
154
40
  }
155
- /** Distinct beers (unique bids) cached for a user. */
156
- distinctBeerCount(username) {
157
- const row = this.db
158
- .prepare('SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL')
159
- .get(username.toLowerCase());
160
- return row.n;
161
- }
162
- /** Highest cached checkin_id for a user (the true newest once contiguous), or null. */
163
- newestCachedId(username) {
164
- const row = this.db
165
- .prepare('SELECT MAX(checkin_id) AS m FROM checkins WHERE username = ?')
166
- .get(username.toLowerCase());
167
- return row.m;
168
- }
169
- getState(username) {
170
- const row = this.db.prepare('SELECT * FROM sync_state WHERE username = ?').get(username.toLowerCase());
171
- if (!row)
172
- return undefined;
173
- return { ...row, username: row.username, backfill_complete: row.backfill_complete === 1 };
174
- }
175
- /**
176
- * Insert-or-merge sync state. Only keys PRESENT in `patch` are written, so an
177
- * explicit `null` (e.g. clearing `oldest_max_id` when backfill completes)
178
- * overrides the stored value — a `??` merge could not do that.
179
- */
180
- setState(username, patch) {
181
- const key = username.toLowerCase();
182
- const cur = this.getState(key);
183
- const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
184
- const next = {
185
- username: key,
186
- oldest_max_id: pick('oldest_max_id', null),
187
- newest_checkin_id: pick('newest_checkin_id', null),
188
- catchup_max_id: pick('catchup_max_id', null),
189
- last_synced_at: pick('last_synced_at', null),
190
- backfill_complete: pick('backfill_complete', false),
191
- total_checkins: pick('total_checkins', null),
192
- };
193
- this.db
194
- .prepare(`INSERT INTO sync_state (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins)
195
- VALUES (?, ?, ?, ?, ?, ?, ?)
196
- ON CONFLICT(username) DO UPDATE SET
197
- oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
198
- catchup_max_id=excluded.catchup_max_id,
199
- last_synced_at=excluded.last_synced_at, backfill_complete=excluded.backfill_complete,
200
- total_checkins=excluded.total_checkins`)
201
- .run(key, next.oldest_max_id, next.newest_checkin_id, next.catchup_max_id, next.last_synced_at, next.backfill_complete ? 1 : 0, next.total_checkins);
41
+ }
42
+ /** A file-backed check-in cache. `open()` creates parent dirs and the schema. */
43
+ export class CheckinCache extends LocalCacheStore {
44
+ db;
45
+ constructor(db) {
46
+ super(new CheckinStoreCore(new NodeSqlDriver(db)));
47
+ this.db = db;
202
48
  }
203
- /** Exact bid match or case-insensitive substring match on beer name. */
204
- hasHad(username, opts) {
205
- const where = ['username = ?'];
206
- const params = [username.toLowerCase()];
207
- if (opts.bid !== undefined) {
208
- where.push('bid = ?');
209
- params.push(opts.bid);
210
- }
211
- if (opts.beerName !== undefined) {
212
- where.push('beer_name LIKE ? COLLATE NOCASE');
213
- params.push(`%${escapeLike(opts.beerName)}%`);
214
- }
215
- const clause = where.join(' AND ');
216
- const agg = this.db
217
- .prepare(`SELECT COUNT(*) AS n, MAX(rating) AS best FROM checkins WHERE ${clause}`)
218
- .get(...params);
219
- // Most-recent first: checkin_id is monotonic in time, a reliable ordering
220
- // even though created_at is a formatted string.
221
- const matches = this.db
222
- .prepare(`SELECT checkin_id, bid, beer_name, rating, venue_name, created_at
223
- FROM checkins WHERE ${clause} ORDER BY checkin_id DESC LIMIT 25`)
224
- .all(...params);
225
- return {
226
- had: agg.n > 0,
227
- count: agg.n,
228
- last_date: matches[0]?.created_at ?? null,
229
- best_rating: agg.best,
230
- matches,
231
- };
49
+ /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
50
+ static open(path) {
51
+ if (path !== ':memory:')
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ return new CheckinCache(new DatabaseSync(path));
232
54
  }
233
- query(username, filters) {
234
- const where = ['username = ?'];
235
- const params = [username.toLowerCase()];
236
- if (filters.brewery_id !== undefined) {
237
- where.push('brewery_id = ?');
238
- params.push(filters.brewery_id);
239
- }
240
- if (filters.brewery !== undefined) {
241
- where.push('brewery_name LIKE ? COLLATE NOCASE');
242
- params.push(`%${escapeLike(filters.brewery)}%`);
243
- }
244
- if (filters.style !== undefined) {
245
- where.push('beer_style LIKE ? COLLATE NOCASE');
246
- params.push(`%${escapeLike(filters.style)}%`);
247
- }
248
- if (filters.min_rating !== undefined) {
249
- where.push('rating >= ?');
250
- params.push(filters.min_rating);
251
- }
252
- if (filters.venue !== undefined) {
253
- where.push('venue_name LIKE ? COLLATE NOCASE');
254
- params.push(`%${escapeLike(filters.venue)}%`);
255
- }
256
- if (filters.venue_id !== undefined) {
257
- where.push('venue_id = ?');
258
- params.push(filters.venue_id);
259
- }
260
- // created_at is ISO-8601, so a YYYY-MM-DD bound compares correctly on its date prefix.
261
- if (filters.date_from !== undefined) {
262
- where.push('substr(created_at, 1, 10) >= ?');
263
- params.push(filters.date_from);
264
- }
265
- if (filters.date_to !== undefined) {
266
- where.push('substr(created_at, 1, 10) <= ?');
267
- params.push(filters.date_to);
268
- }
269
- const order = filters.sort === 'oldest'
270
- ? 'checkin_id ASC'
271
- : filters.sort === 'highest_rated'
272
- ? 'rating DESC, checkin_id DESC'
273
- : filters.sort === 'lowest_rated'
274
- ? 'rating ASC, checkin_id DESC'
275
- : 'checkin_id DESC';
276
- const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
277
- const rows = this.db
278
- .prepare(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`)
279
- .all(...params, limit);
280
- return rows;
55
+ close() {
56
+ this.db.close();
281
57
  }
282
58
  }
283
- // Escape LIKE wildcards in user input so a literal % or _ isn't treated as a
284
- // wildcard. Our LIKE patterns don't set an ESCAPE clause, so we only need to
285
- // avoid a query that matches unexpectedly; backslash-escaping the specials and
286
- // pairing with the default is not portable, so we simply strip them.
287
- function escapeLike(s) {
288
- return s.replace(/[%_]/g, ' ');
289
- }
290
59
  /** Default on-disk cache path: `$UNTAPPD_CACHE_DB` or `~/.untappd-mcp/checkins.db`. */
291
60
  export function defaultCachePath() {
292
61
  return readEnvVar('UNTAPPD_CACHE_DB') ?? join(homedir(), '.untappd-mcp', 'checkins.db');
293
62
  }
63
+ // Re-export the shared types/helpers tests and tools import from here so their
64
+ // existing import paths keep working.
65
+ export { mapCheckinRow, } from './store.js';
@@ -0,0 +1,333 @@
1
+ // Storage-agnostic core for the Untappd check-in cache.
2
+ //
3
+ // The cache exists because the Untappd API only exposes paged, most-recent-first
4
+ // lists (50/page) and NO "has user X ever had beer Y" lookup — answering that
5
+ // from the API means paging the entire history every time, against a
6
+ // ~100-calls/hour rate limit. We sync once (incrementally + resumable backfill)
7
+ // into this cache, then answer every "has had" / filter query locally with zero
8
+ // API calls.
9
+ //
10
+ // The SQL logic lives here ONCE, parameterised over a tiny synchronous
11
+ // {@link SqlDriver}, so the exact same schema and queries back both storage
12
+ // engines: `node:sqlite` on the stdio server (src/cache/db.ts) and a Cloudflare
13
+ // Durable Object's SQLite storage on the remote connector (src/cache/durable.ts).
14
+ // This module imports nothing platform-specific so it is safe in both the Node
15
+ // and Workers bundles.
16
+ function asDict(v) {
17
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
18
+ }
19
+ function num(v) {
20
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
21
+ }
22
+ function str(v) {
23
+ return typeof v === 'string' && v.length > 0 ? v : null;
24
+ }
25
+ /**
26
+ * Normalise a raw `/user/checkins` item to a {@link CheckinRow}. Reads only the
27
+ * documented list fields (the same ones `compactCheckin` uses) and derives
28
+ * nothing it can't. `created_at` is normalised to ISO-8601 UTC so it sorts
29
+ * lexicographically = chronologically and date-range filters are plain string
30
+ * comparisons; the raw RFC-2822 string is kept if it can't be parsed.
31
+ */
32
+ export function mapCheckinRow(username, item) {
33
+ const c = asDict(item);
34
+ const checkin_id = num(c.checkin_id);
35
+ if (checkin_id === null)
36
+ return null; // can't key it — skip rather than corrupt the table
37
+ const beer = asDict(c.beer);
38
+ const brewery = asDict(c.brewery);
39
+ const venue = asDict(c.venue); // Untappd sends `venue: []` when none — asDict → {}
40
+ return {
41
+ checkin_id,
42
+ username,
43
+ bid: num(beer.bid),
44
+ beer_name: str(beer.beer_name),
45
+ brewery_id: num(brewery.brewery_id),
46
+ brewery_name: str(brewery.brewery_name),
47
+ beer_style: str(beer.beer_style),
48
+ abv: num(beer.beer_abv),
49
+ rating: num(c.rating_score),
50
+ comment: str(c.checkin_comment),
51
+ venue_id: num(venue.venue_id),
52
+ venue_name: str(venue.venue_name),
53
+ created_at: normaliseDate(c.created_at),
54
+ };
55
+ }
56
+ function normaliseDate(v) {
57
+ const s = str(v);
58
+ if (!s)
59
+ return null;
60
+ const t = Date.parse(s);
61
+ return Number.isNaN(t) ? s : new Date(t).toISOString();
62
+ }
63
+ /** Schema statements, split so a driver that only runs one statement per call works. */
64
+ export const SCHEMA_STATEMENTS = [
65
+ `CREATE TABLE IF NOT EXISTS checkins (
66
+ checkin_id INTEGER PRIMARY KEY,
67
+ username TEXT NOT NULL,
68
+ bid INTEGER,
69
+ beer_name TEXT,
70
+ brewery_id INTEGER,
71
+ brewery_name TEXT,
72
+ beer_style TEXT,
73
+ abv REAL,
74
+ rating REAL,
75
+ comment TEXT,
76
+ venue_id INTEGER,
77
+ venue_name TEXT,
78
+ created_at TEXT
79
+ )`,
80
+ `CREATE INDEX IF NOT EXISTS idx_checkins_user_bid ON checkins(username, bid)`,
81
+ `CREATE INDEX IF NOT EXISTS idx_checkins_user_brewery ON checkins(username, brewery_id)`,
82
+ `CREATE TABLE IF NOT EXISTS sync_state (
83
+ username TEXT PRIMARY KEY,
84
+ oldest_max_id INTEGER,
85
+ newest_checkin_id INTEGER,
86
+ catchup_max_id INTEGER,
87
+ last_synced_at TEXT,
88
+ backfill_complete INTEGER NOT NULL DEFAULT 0,
89
+ total_checkins INTEGER
90
+ )`,
91
+ ];
92
+ /** Columns added after the initial release, applied idempotently on open. */
93
+ export const MIGRATIONS = ['ALTER TABLE sync_state ADD COLUMN catchup_max_id INTEGER'];
94
+ /**
95
+ * The check-in cache logic over a synchronous {@link SqlDriver}. Usernames are
96
+ * keyed case-insensitively via a lowercased key so `Mer1331` and `mer1331` share
97
+ * one cache; stored rows keep whatever casing they arrived with in other fields.
98
+ */
99
+ export class CheckinStoreCore {
100
+ db;
101
+ constructor(db) {
102
+ this.db = db;
103
+ for (const stmt of SCHEMA_STATEMENTS)
104
+ this.db.execScript(stmt);
105
+ // Forward-compat for a sync_state table created before a column existed; the
106
+ // ADD COLUMN throws harmlessly if the column is already present.
107
+ for (const mig of MIGRATIONS) {
108
+ try {
109
+ this.db.execScript(mig);
110
+ }
111
+ catch {
112
+ /* column already exists */
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Upsert a batch of rows (dedupe on checkin_id) inside one transaction and
118
+ * return the NET number of newly-inserted rows (updates to existing rows count
119
+ * as 0), which is what "rows added" reports.
120
+ */
121
+ upsertCheckins(username, rows) {
122
+ if (rows.length === 0)
123
+ return 0;
124
+ const key = username.toLowerCase();
125
+ const before = this.cachedCount(username);
126
+ const sql = `
127
+ INSERT INTO checkins
128
+ (checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
130
+ ON CONFLICT(checkin_id) DO UPDATE SET
131
+ username=excluded.username, bid=excluded.bid, beer_name=excluded.beer_name,
132
+ brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
133
+ abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
134
+ venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at`;
135
+ this.db.transaction(() => {
136
+ for (const r of rows) {
137
+ this.db.run(sql, [
138
+ r.checkin_id,
139
+ key,
140
+ r.bid,
141
+ r.beer_name,
142
+ r.brewery_id,
143
+ r.brewery_name,
144
+ r.beer_style,
145
+ r.abv,
146
+ r.rating,
147
+ r.comment,
148
+ r.venue_id,
149
+ r.venue_name,
150
+ r.created_at,
151
+ ]);
152
+ }
153
+ });
154
+ return this.cachedCount(username) - before;
155
+ }
156
+ /** Total cached check-ins for a user (= distinct check-ins, since PK is checkin_id). */
157
+ cachedCount(username) {
158
+ const row = this.db.get('SELECT COUNT(*) AS n FROM checkins WHERE username = ?', [username.toLowerCase()]);
159
+ return Number(row?.n ?? 0);
160
+ }
161
+ /** Distinct beers (unique bids) cached for a user. */
162
+ distinctBeerCount(username) {
163
+ const row = this.db.get('SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL', [username.toLowerCase()]);
164
+ return Number(row?.n ?? 0);
165
+ }
166
+ /** Highest cached checkin_id for a user (the true newest once contiguous), or null. */
167
+ newestCachedId(username) {
168
+ const row = this.db.get('SELECT MAX(checkin_id) AS m FROM checkins WHERE username = ?', [username.toLowerCase()]);
169
+ return num(row?.m);
170
+ }
171
+ getState(username) {
172
+ const row = this.db.get('SELECT * FROM sync_state WHERE username = ?', [username.toLowerCase()]);
173
+ if (!row)
174
+ return undefined;
175
+ return {
176
+ username: String(row.username),
177
+ oldest_max_id: num(row.oldest_max_id),
178
+ newest_checkin_id: num(row.newest_checkin_id),
179
+ catchup_max_id: num(row.catchup_max_id),
180
+ last_synced_at: str(row.last_synced_at),
181
+ backfill_complete: Number(row.backfill_complete) === 1,
182
+ total_checkins: num(row.total_checkins),
183
+ };
184
+ }
185
+ /**
186
+ * Insert-or-merge sync state. Only keys PRESENT in `patch` are written, so an
187
+ * explicit `null` (e.g. clearing `oldest_max_id` when backfill completes)
188
+ * overrides the stored value — a `??` merge could not do that.
189
+ */
190
+ setState(username, patch) {
191
+ const key = username.toLowerCase();
192
+ const cur = this.getState(key);
193
+ const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
194
+ const next = {
195
+ username: key,
196
+ oldest_max_id: pick('oldest_max_id', null),
197
+ newest_checkin_id: pick('newest_checkin_id', null),
198
+ catchup_max_id: pick('catchup_max_id', null),
199
+ last_synced_at: pick('last_synced_at', null),
200
+ backfill_complete: pick('backfill_complete', false),
201
+ total_checkins: pick('total_checkins', null),
202
+ };
203
+ this.db.run(`INSERT INTO sync_state (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins)
204
+ VALUES (?, ?, ?, ?, ?, ?, ?)
205
+ ON CONFLICT(username) DO UPDATE SET
206
+ oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
207
+ catchup_max_id=excluded.catchup_max_id,
208
+ last_synced_at=excluded.last_synced_at, backfill_complete=excluded.backfill_complete,
209
+ total_checkins=excluded.total_checkins`, [
210
+ key,
211
+ next.oldest_max_id,
212
+ next.newest_checkin_id,
213
+ next.catchup_max_id,
214
+ next.last_synced_at,
215
+ next.backfill_complete ? 1 : 0,
216
+ next.total_checkins,
217
+ ]);
218
+ }
219
+ /** Exact bid match or case-insensitive substring match on beer name. */
220
+ hasHad(username, opts) {
221
+ const where = ['username = ?'];
222
+ const params = [username.toLowerCase()];
223
+ if (opts.bid !== undefined) {
224
+ where.push('bid = ?');
225
+ params.push(opts.bid);
226
+ }
227
+ if (opts.beerName !== undefined) {
228
+ where.push('beer_name LIKE ? COLLATE NOCASE');
229
+ params.push(`%${escapeLike(opts.beerName)}%`);
230
+ }
231
+ const clause = where.join(' AND ');
232
+ const agg = this.db.get(`SELECT COUNT(*) AS n, MAX(rating) AS best FROM checkins WHERE ${clause}`, params);
233
+ // Most-recent first: checkin_id is monotonic in time, a reliable ordering
234
+ // even though created_at is a formatted string.
235
+ const matches = this.db.all(`SELECT checkin_id, bid, beer_name, rating, venue_name, created_at
236
+ FROM checkins WHERE ${clause} ORDER BY checkin_id DESC LIMIT 25`, params);
237
+ const count = Number(agg?.n ?? 0);
238
+ return {
239
+ had: count > 0,
240
+ count,
241
+ last_date: matches[0]?.created_at ?? null,
242
+ best_rating: num(agg?.best),
243
+ matches,
244
+ };
245
+ }
246
+ query(username, filters) {
247
+ const where = ['username = ?'];
248
+ const params = [username.toLowerCase()];
249
+ if (filters.brewery_id !== undefined) {
250
+ where.push('brewery_id = ?');
251
+ params.push(filters.brewery_id);
252
+ }
253
+ if (filters.brewery !== undefined) {
254
+ where.push('brewery_name LIKE ? COLLATE NOCASE');
255
+ params.push(`%${escapeLike(filters.brewery)}%`);
256
+ }
257
+ if (filters.style !== undefined) {
258
+ where.push('beer_style LIKE ? COLLATE NOCASE');
259
+ params.push(`%${escapeLike(filters.style)}%`);
260
+ }
261
+ if (filters.min_rating !== undefined) {
262
+ where.push('rating >= ?');
263
+ params.push(filters.min_rating);
264
+ }
265
+ if (filters.venue !== undefined) {
266
+ where.push('venue_name LIKE ? COLLATE NOCASE');
267
+ params.push(`%${escapeLike(filters.venue)}%`);
268
+ }
269
+ if (filters.venue_id !== undefined) {
270
+ where.push('venue_id = ?');
271
+ params.push(filters.venue_id);
272
+ }
273
+ // created_at is ISO-8601, so a YYYY-MM-DD bound compares correctly on its date prefix.
274
+ if (filters.date_from !== undefined) {
275
+ where.push('substr(created_at, 1, 10) >= ?');
276
+ params.push(filters.date_from);
277
+ }
278
+ if (filters.date_to !== undefined) {
279
+ where.push('substr(created_at, 1, 10) <= ?');
280
+ params.push(filters.date_to);
281
+ }
282
+ const order = filters.sort === 'oldest'
283
+ ? 'checkin_id ASC'
284
+ : filters.sort === 'highest_rated'
285
+ ? 'rating DESC, checkin_id DESC'
286
+ : filters.sort === 'lowest_rated'
287
+ ? 'rating ASC, checkin_id DESC'
288
+ : 'checkin_id DESC';
289
+ const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
290
+ return this.db.all(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`, [...params, limit]);
291
+ }
292
+ }
293
+ /**
294
+ * Adapts a synchronous {@link CheckinStoreCore} to the async {@link CacheStore}
295
+ * interface. Used by the in-process node backend; the Durable Object backend
296
+ * implements CacheStore over a real RPC boundary instead.
297
+ */
298
+ export class LocalCacheStore {
299
+ core;
300
+ constructor(core) {
301
+ this.core = core;
302
+ }
303
+ async upsertCheckins(username, rows) {
304
+ return this.core.upsertCheckins(username, rows);
305
+ }
306
+ async cachedCount(username) {
307
+ return this.core.cachedCount(username);
308
+ }
309
+ async distinctBeerCount(username) {
310
+ return this.core.distinctBeerCount(username);
311
+ }
312
+ async newestCachedId(username) {
313
+ return this.core.newestCachedId(username);
314
+ }
315
+ async getState(username) {
316
+ return this.core.getState(username);
317
+ }
318
+ async setState(username, patch) {
319
+ this.core.setState(username, patch);
320
+ }
321
+ async hasHad(username, opts) {
322
+ return this.core.hasHad(username, opts);
323
+ }
324
+ async query(username, filters) {
325
+ return this.core.query(username, filters);
326
+ }
327
+ }
328
+ // Escape LIKE wildcards in user input so a literal % or _ isn't treated as a
329
+ // wildcard. Our LIKE patterns don't set an ESCAPE clause, so we simply strip the
330
+ // specials rather than rely on a non-portable escape convention.
331
+ function escapeLike(s) {
332
+ return s.replace(/[%_]/g, ' ');
333
+ }