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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -6
- package/SKILL.md +5 -5
- package/dist/bundle.js +236 -138
- package/dist/cache/db.js +36 -264
- package/dist/cache/store.js +333 -0
- package/dist/cache/sync.js +11 -11
- package/dist/index.js +12 -4
- package/dist/tools/cache.js +24 -30
- package/dist/tools/diagnostics.js +33 -0
- package/dist/tools/utilities.js +10 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
this.db.
|
|
24
|
+
get(sql, params) {
|
|
25
|
+
return this.db.prepare(sql).get(...params);
|
|
114
26
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
+
}
|