untappd-mcp 2.3.2 → 2.5.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 +185 -5
- package/dist/bundle.js +6579 -1752
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/db.js +100 -10
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1424 -24
- package/dist/cache/sync-beers.js +51 -7
- package/dist/cache/sync-brewery-likes.js +124 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +38 -0
- package/dist/compact.js +47 -42
- package/dist/index.js +27 -4
- package/dist/menu/match.js +178 -0
- package/dist/menu/pick.js +520 -0
- package/dist/menu/venue-menu.js +108 -0
- package/dist/quota.js +14 -0
- package/dist/ratelimit.js +236 -0
- package/dist/tools/beer.js +4 -1
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +506 -62
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/menu.js +104 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +91 -115
- package/dist/version.js +1 -1
- package/mint.yaml +17 -0
- package/package.json +5 -1
- package/server.json +16 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
package/dist/cache/store.js
CHANGED
|
@@ -17,12 +17,21 @@
|
|
|
17
17
|
// schema/queries back any engine: `node:sqlite` is the one that ships, and a
|
|
18
18
|
// different deployment can adapt another driver to the same surface. This
|
|
19
19
|
// module imports nothing platform-specific.
|
|
20
|
+
/** Default radius for a lat/lng has-visited query. */
|
|
21
|
+
export const DEFAULT_VISIT_RADIUS_M = 150;
|
|
22
|
+
export const FAVORITE_KINDS = ['beer', 'brewery', 'venue'];
|
|
20
23
|
function asDict(v) {
|
|
21
24
|
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
22
25
|
}
|
|
23
26
|
function num(v) {
|
|
24
27
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
25
28
|
}
|
|
29
|
+
/** A number, or a numeric string (coordinates sometimes arrive as strings). */
|
|
30
|
+
function numLoose(v) {
|
|
31
|
+
if (typeof v === 'string' && v.trim() !== '')
|
|
32
|
+
return num(Number(v));
|
|
33
|
+
return num(v);
|
|
34
|
+
}
|
|
26
35
|
function str(v) {
|
|
27
36
|
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
28
37
|
}
|
|
@@ -82,6 +91,107 @@ export function mapBeerRow(username, item) {
|
|
|
82
91
|
last_had: normaliseDate(c.recent_created_at ?? c.first_created_at),
|
|
83
92
|
};
|
|
84
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Normalise a raw `/user/venues` item to the shared venue facts plus the user's
|
|
96
|
+
* visit summary. Dates become ISO-8601 UTC (sortable, comparable across syncs).
|
|
97
|
+
* Returns null without a venue id (Untappd sends `venue: []` for none).
|
|
98
|
+
*/
|
|
99
|
+
export function mapUserVenueItem(_username, item) {
|
|
100
|
+
const c = asDict(item);
|
|
101
|
+
const v = asDict(c.venue);
|
|
102
|
+
const venue_id = num(v.venue_id);
|
|
103
|
+
if (venue_id === null)
|
|
104
|
+
return null;
|
|
105
|
+
const loc = asDict(v.location);
|
|
106
|
+
const verified = v.is_verified;
|
|
107
|
+
return {
|
|
108
|
+
venue: {
|
|
109
|
+
venue_id,
|
|
110
|
+
name: str(v.venue_name),
|
|
111
|
+
category: str(v.primary_category),
|
|
112
|
+
address: str(loc.venue_address),
|
|
113
|
+
city: str(loc.venue_city),
|
|
114
|
+
state: str(loc.venue_state),
|
|
115
|
+
country: str(loc.venue_country),
|
|
116
|
+
lat: numLoose(loc.lat),
|
|
117
|
+
lng: numLoose(loc.lng),
|
|
118
|
+
is_verified: typeof verified === 'boolean' || typeof verified === 'number' ? (verified ? 1 : 0) : null,
|
|
119
|
+
},
|
|
120
|
+
visit: {
|
|
121
|
+
venue_id,
|
|
122
|
+
first_visit: normaliseDate(c.first_visit),
|
|
123
|
+
first_checkin_id: num(c.first_checkin_id),
|
|
124
|
+
recent_visit: normaliseDate(c.recent_visit),
|
|
125
|
+
recent_checkin_id: num(c.recent_checkin_id),
|
|
126
|
+
total_count: num(c.total_count),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Map one brewery/beer_list item to a {@link BreweryBeerRow}. Takes only the
|
|
132
|
+
* global facts: the item's `has_had` / `total_user_count` are the AUTHENTICATED
|
|
133
|
+
* account's and are deliberately dropped. Returns null without a bid.
|
|
134
|
+
*/
|
|
135
|
+
export function mapBreweryBeerItem(breweryId, item, fetchedAt, popularityRank) {
|
|
136
|
+
const i = asDict(item);
|
|
137
|
+
const b = asDict(i.beer);
|
|
138
|
+
const bid = num(b.bid);
|
|
139
|
+
if (bid === null)
|
|
140
|
+
return null;
|
|
141
|
+
const inProd = num(b.is_in_production);
|
|
142
|
+
return {
|
|
143
|
+
brewery_id: breweryId,
|
|
144
|
+
bid,
|
|
145
|
+
beer_name: str(b.beer_name),
|
|
146
|
+
style: str(b.beer_style),
|
|
147
|
+
abv: num(b.beer_abv),
|
|
148
|
+
ibu: num(b.beer_ibu),
|
|
149
|
+
rating_score: num(b.rating_score),
|
|
150
|
+
rating_count: num(b.rating_count),
|
|
151
|
+
checkin_count: num(i.total_count),
|
|
152
|
+
in_production: inProd === null ? null : inProd !== 0,
|
|
153
|
+
popularity_rank: popularityRank,
|
|
154
|
+
fetched_at: fetchedAt,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Map one user/wishlist item; null without a bid. */
|
|
158
|
+
export function mapWishlistItem(item) {
|
|
159
|
+
const i = asDict(item);
|
|
160
|
+
const b = asDict(i.beer);
|
|
161
|
+
const bid = num(b.bid);
|
|
162
|
+
if (bid === null)
|
|
163
|
+
return null;
|
|
164
|
+
const brw = asDict(i.brewery);
|
|
165
|
+
return {
|
|
166
|
+
bid,
|
|
167
|
+
beer_name: str(b.beer_name),
|
|
168
|
+
brewery_id: num(brw.brewery_id),
|
|
169
|
+
brewery_name: str(brw.brewery_name),
|
|
170
|
+
style: str(b.beer_style),
|
|
171
|
+
abv: num(b.beer_abv),
|
|
172
|
+
added_at: normaliseDate(i.created_at),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/** Map one user/brewerylikes item; null without a brewery_id. */
|
|
176
|
+
export function mapBreweryLikeItem(item) {
|
|
177
|
+
const i = asDict(item);
|
|
178
|
+
const b = asDict(i.brewery);
|
|
179
|
+
const id = num(b.brewery_id);
|
|
180
|
+
if (id === null)
|
|
181
|
+
return null;
|
|
182
|
+
const loc = asDict(b.location);
|
|
183
|
+
return {
|
|
184
|
+
brewery_id: id,
|
|
185
|
+
brewery_name: str(b.brewery_name),
|
|
186
|
+
brewery_type: str(b.brewery_type),
|
|
187
|
+
country: str(b.country_name),
|
|
188
|
+
city: str(loc.brewery_city),
|
|
189
|
+
state: str(loc.brewery_state),
|
|
190
|
+
lat: num(loc.lat),
|
|
191
|
+
lng: num(loc.lng),
|
|
192
|
+
liked_at: normaliseDate(i.created_at),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
85
195
|
/**
|
|
86
196
|
* Normalise a beer object (from beer/info's `beer`, or a search/list item's
|
|
87
197
|
* `beer` with a sibling `brewery`) to {@link BeerMeta}. `fetchedAt` stamps when
|
|
@@ -180,7 +290,11 @@ export const SCHEMA_STATEMENTS = [
|
|
|
180
290
|
beers_rescan_target INTEGER
|
|
181
291
|
)`,
|
|
182
292
|
];
|
|
183
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* Columns added to the v1 tables before the schema was versioned. A database
|
|
295
|
+
* at user_version 0 may predate any of them, so each is applied idempotently
|
|
296
|
+
* (ADD COLUMN throws harmlessly when the column already exists).
|
|
297
|
+
*/
|
|
184
298
|
export const MIGRATIONS = [
|
|
185
299
|
'ALTER TABLE sync_state ADD COLUMN catchup_max_id INTEGER',
|
|
186
300
|
'ALTER TABLE sync_state ADD COLUMN checkins_truncated INTEGER NOT NULL DEFAULT 0',
|
|
@@ -191,6 +305,207 @@ export const MIGRATIONS = [
|
|
|
191
305
|
'ALTER TABLE sync_state ADD COLUMN beers_accepted_gap INTEGER NOT NULL DEFAULT 0',
|
|
192
306
|
'ALTER TABLE sync_state ADD COLUMN beers_rescan_target INTEGER',
|
|
193
307
|
];
|
|
308
|
+
/**
|
|
309
|
+
* Forward-only, versioned migrations, tracked in SQLite's `PRAGMA user_version`.
|
|
310
|
+
* Version 1 is the baseline (SCHEMA_STATEMENTS + the legacy MIGRATIONS above);
|
|
311
|
+
* every later change is appended here with the next version number and is
|
|
312
|
+
* ADDITIVE — a migration never drops or rewrites user rows, so upgrading can't
|
|
313
|
+
* silently wipe a cache that took an hour of rate-limited calls to fill.
|
|
314
|
+
*/
|
|
315
|
+
export const VERSIONED_MIGRATIONS = [
|
|
316
|
+
{
|
|
317
|
+
version: 2,
|
|
318
|
+
statements: [
|
|
319
|
+
`CREATE TABLE IF NOT EXISTS item_resolutions (
|
|
320
|
+
key TEXT PRIMARY KEY,
|
|
321
|
+
item TEXT NOT NULL,
|
|
322
|
+
status TEXT NOT NULL,
|
|
323
|
+
bid INTEGER,
|
|
324
|
+
data TEXT,
|
|
325
|
+
resolved_at TEXT NOT NULL
|
|
326
|
+
)`,
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
// untappd_cache_query's default (and date-range) ordering.
|
|
331
|
+
version: 3,
|
|
332
|
+
statements: ['CREATE INDEX IF NOT EXISTS idx_checkins_user_created ON checkins(username, created_at)'],
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
// Venue history, list syncs, TTL caches, favorites and the API call log.
|
|
336
|
+
// New tables only — nothing existing is altered.
|
|
337
|
+
version: 4,
|
|
338
|
+
statements: [
|
|
339
|
+
// Global venue facts from user/venues (and venue/info), shared across users.
|
|
340
|
+
`CREATE TABLE IF NOT EXISTS venues (
|
|
341
|
+
venue_id INTEGER PRIMARY KEY,
|
|
342
|
+
name TEXT,
|
|
343
|
+
category TEXT,
|
|
344
|
+
address TEXT,
|
|
345
|
+
city TEXT,
|
|
346
|
+
state TEXT,
|
|
347
|
+
country TEXT,
|
|
348
|
+
lat REAL,
|
|
349
|
+
lng REAL,
|
|
350
|
+
is_verified INTEGER,
|
|
351
|
+
updated_at TEXT
|
|
352
|
+
)`,
|
|
353
|
+
`CREATE INDEX IF NOT EXISTS idx_venues_city ON venues(city COLLATE NOCASE)`,
|
|
354
|
+
`CREATE INDEX IF NOT EXISTS idx_venues_lat_lng ON venues(lat, lng)`,
|
|
355
|
+
// One row per (user, venue) from user/venues: the user's visit summary there.
|
|
356
|
+
`CREATE TABLE IF NOT EXISTS user_venues (
|
|
357
|
+
username TEXT NOT NULL,
|
|
358
|
+
venue_id INTEGER NOT NULL,
|
|
359
|
+
first_visit TEXT,
|
|
360
|
+
first_checkin_id INTEGER,
|
|
361
|
+
recent_visit TEXT,
|
|
362
|
+
recent_checkin_id INTEGER,
|
|
363
|
+
total_count INTEGER,
|
|
364
|
+
synced_at TEXT,
|
|
365
|
+
PRIMARY KEY (username, venue_id)
|
|
366
|
+
)`,
|
|
367
|
+
`CREATE INDEX IF NOT EXISTS idx_user_venues_recent ON user_venues(username, recent_visit)`,
|
|
368
|
+
// Resumable cursor + reported total for offset-paged per-user lists
|
|
369
|
+
// (list = 'venues' | 'wishlist' | …). Kept out of sync_state so each list
|
|
370
|
+
// owns its own bookkeeping.
|
|
371
|
+
`CREATE TABLE IF NOT EXISTS list_sync_state (
|
|
372
|
+
username TEXT NOT NULL,
|
|
373
|
+
list TEXT NOT NULL,
|
|
374
|
+
next_offset INTEGER,
|
|
375
|
+
total_count INTEGER,
|
|
376
|
+
complete INTEGER NOT NULL DEFAULT 0,
|
|
377
|
+
last_synced_at TEXT,
|
|
378
|
+
PRIMARY KEY (username, list)
|
|
379
|
+
)`,
|
|
380
|
+
// brewery/beer_list results, per brewery, with a TTL on the list as a whole.
|
|
381
|
+
// The response's have_had reflects the AUTHENTICATED account, so it is not stored.
|
|
382
|
+
// fetched_at is when the current snapshot STARTED (its TTL clock);
|
|
383
|
+
// ranked_count is how many beers from the top of Untappd's default
|
|
384
|
+
// (popularity) order are cached contiguously.
|
|
385
|
+
`CREATE TABLE IF NOT EXISTS brewery_beer_lists (
|
|
386
|
+
brewery_id INTEGER PRIMARY KEY,
|
|
387
|
+
brewery_name TEXT,
|
|
388
|
+
total_count INTEGER,
|
|
389
|
+
ranked_count INTEGER NOT NULL DEFAULT 0,
|
|
390
|
+
fetched_at TEXT NOT NULL
|
|
391
|
+
)`,
|
|
392
|
+
`CREATE TABLE IF NOT EXISTS brewery_beers (
|
|
393
|
+
brewery_id INTEGER NOT NULL,
|
|
394
|
+
bid INTEGER NOT NULL,
|
|
395
|
+
beer_name TEXT,
|
|
396
|
+
style TEXT,
|
|
397
|
+
abv REAL,
|
|
398
|
+
rating_score REAL,
|
|
399
|
+
rating_count INTEGER,
|
|
400
|
+
checkin_count INTEGER,
|
|
401
|
+
in_production INTEGER,
|
|
402
|
+
popularity_rank INTEGER,
|
|
403
|
+
fetched_at TEXT NOT NULL,
|
|
404
|
+
PRIMARY KEY (brewery_id, bid)
|
|
405
|
+
)`,
|
|
406
|
+
// Short-TTL raw response cache (venue menus / activity), keyed e.g. 'venue_menu:123'.
|
|
407
|
+
`CREATE TABLE IF NOT EXISTS response_cache (
|
|
408
|
+
key TEXT PRIMARY KEY,
|
|
409
|
+
kind TEXT NOT NULL,
|
|
410
|
+
data TEXT NOT NULL,
|
|
411
|
+
fetched_at TEXT NOT NULL
|
|
412
|
+
)`,
|
|
413
|
+
`CREATE TABLE IF NOT EXISTS wishlist (
|
|
414
|
+
username TEXT NOT NULL,
|
|
415
|
+
bid INTEGER NOT NULL,
|
|
416
|
+
beer_name TEXT,
|
|
417
|
+
brewery_id INTEGER,
|
|
418
|
+
brewery_name TEXT,
|
|
419
|
+
style TEXT,
|
|
420
|
+
abv REAL,
|
|
421
|
+
added_at TEXT,
|
|
422
|
+
synced_at TEXT NOT NULL,
|
|
423
|
+
PRIMARY KEY (username, bid)
|
|
424
|
+
)`,
|
|
425
|
+
// Manual favorites — local only, never written to Untappd.
|
|
426
|
+
`CREATE TABLE IF NOT EXISTS favorites (
|
|
427
|
+
username TEXT NOT NULL,
|
|
428
|
+
kind TEXT NOT NULL CHECK (kind IN ('beer', 'brewery', 'venue')),
|
|
429
|
+
id INTEGER NOT NULL,
|
|
430
|
+
note TEXT,
|
|
431
|
+
created_at TEXT NOT NULL,
|
|
432
|
+
PRIMARY KEY (username, kind, id)
|
|
433
|
+
)`,
|
|
434
|
+
// What a user drank at a venue, from checkin/view of the user_venues first/recent check-ins.
|
|
435
|
+
`CREATE TABLE IF NOT EXISTS venue_drinks (
|
|
436
|
+
username TEXT NOT NULL,
|
|
437
|
+
venue_id INTEGER NOT NULL,
|
|
438
|
+
checkin_id INTEGER NOT NULL,
|
|
439
|
+
bid INTEGER,
|
|
440
|
+
beer_name TEXT,
|
|
441
|
+
brewery_id INTEGER,
|
|
442
|
+
brewery_name TEXT,
|
|
443
|
+
created_at TEXT,
|
|
444
|
+
fetched_at TEXT NOT NULL,
|
|
445
|
+
PRIMARY KEY (username, checkin_id)
|
|
446
|
+
)`,
|
|
447
|
+
`CREATE INDEX IF NOT EXISTS idx_venue_drinks_user_venue ON venue_drinks(username, venue_id)`,
|
|
448
|
+
// Every API response's time/status/rate-limit headers (src/ratelimit.ts),
|
|
449
|
+
// shared by all processes using this file. Pruned to ~2 hours.
|
|
450
|
+
`CREATE TABLE IF NOT EXISTS api_calls (
|
|
451
|
+
at INTEGER NOT NULL,
|
|
452
|
+
status INTEGER NOT NULL,
|
|
453
|
+
remaining INTEGER,
|
|
454
|
+
limit_hdr INTEGER,
|
|
455
|
+
reset_at INTEGER,
|
|
456
|
+
retry_after_s INTEGER
|
|
457
|
+
)`,
|
|
458
|
+
`CREATE INDEX IF NOT EXISTS idx_api_calls_at ON api_calls(at)`,
|
|
459
|
+
],
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
// Untappd brewery likes (user/brewerylikes, the app's "following") — the
|
|
463
|
+
// "untappd" favorite source. A new table only; nothing existing is altered.
|
|
464
|
+
version: 5,
|
|
465
|
+
statements: [
|
|
466
|
+
`CREATE TABLE IF NOT EXISTS brewery_likes (
|
|
467
|
+
username TEXT NOT NULL,
|
|
468
|
+
brewery_id INTEGER NOT NULL,
|
|
469
|
+
brewery_name TEXT,
|
|
470
|
+
brewery_type TEXT,
|
|
471
|
+
country TEXT,
|
|
472
|
+
city TEXT,
|
|
473
|
+
state TEXT,
|
|
474
|
+
lat REAL,
|
|
475
|
+
lng REAL,
|
|
476
|
+
liked_at TEXT,
|
|
477
|
+
synced_at TEXT NOT NULL,
|
|
478
|
+
PRIMARY KEY (username, brewery_id)
|
|
479
|
+
)`,
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
// brewery/beer_list items carry `beer_ibu`, which the old live-only compact
|
|
484
|
+
// projection returned; caching it keeps untappd_brewery_beers' compact shape
|
|
485
|
+
// identical with and without the cache. Additive: rows cached before this
|
|
486
|
+
// read back ibu null until the list next refreshes (7-day TTL).
|
|
487
|
+
version: 6,
|
|
488
|
+
statements: [`ALTER TABLE brewery_beers ADD COLUMN ibu REAL`],
|
|
489
|
+
},
|
|
490
|
+
];
|
|
491
|
+
/** Tables holding one user's data, cleared by {@link CheckinStoreCore.forgetUser}. */
|
|
492
|
+
export const PER_USER_TABLES = [
|
|
493
|
+
'checkins',
|
|
494
|
+
'distinct_beers',
|
|
495
|
+
'sync_state',
|
|
496
|
+
'user_venues',
|
|
497
|
+
'list_sync_state',
|
|
498
|
+
'wishlist',
|
|
499
|
+
'favorites',
|
|
500
|
+
'venue_drinks',
|
|
501
|
+
'brewery_likes',
|
|
502
|
+
];
|
|
503
|
+
/** How long short-TTL cached responses are kept at all (longer than any TTL that can serve them). */
|
|
504
|
+
export const RESPONSE_CACHE_RETENTION_MS = 48 * 60 * 60 * 1000;
|
|
505
|
+
/** How long the persisted API call log is kept (it only needs the rolling hour). */
|
|
506
|
+
const API_CALL_LOG_RETENTION_MS = 2 * 60 * 60 * 1000;
|
|
507
|
+
/** The schema version this code writes. */
|
|
508
|
+
export const SCHEMA_VERSION = VERSIONED_MIGRATIONS.reduce((v, m) => Math.max(v, m.version), 1);
|
|
194
509
|
/**
|
|
195
510
|
* The cache logic over a synchronous {@link SqlDriver}. Usernames are keyed
|
|
196
511
|
* case-insensitively via a lowercased key so `Mer1331` and `mer1331` share one
|
|
@@ -198,20 +513,63 @@ export const MIGRATIONS = [
|
|
|
198
513
|
*/
|
|
199
514
|
export class CheckinStoreCore {
|
|
200
515
|
db;
|
|
516
|
+
version = 0;
|
|
201
517
|
constructor(db) {
|
|
202
518
|
this.db = db;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
519
|
+
this.migrate();
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Bring the schema up to {@link SCHEMA_VERSION}. Runs in ONE write
|
|
523
|
+
* transaction and re-reads the version inside it, so two processes opening a
|
|
524
|
+
* fresh file at once can't both apply the same step. A file written by a
|
|
525
|
+
* NEWER build is left untouched (its migrations are additive, so this build's
|
|
526
|
+
* queries still work) rather than "repaired" downwards.
|
|
527
|
+
*/
|
|
528
|
+
migrate() {
|
|
529
|
+
this.db.transaction(() => {
|
|
530
|
+
const current = Number(this.db.get('PRAGMA user_version', [])?.user_version ?? 0);
|
|
531
|
+
if (current > SCHEMA_VERSION) {
|
|
532
|
+
console.error(`[untappd-mcp] cache schema v${current} is newer than this build (v${SCHEMA_VERSION}); using it as-is.`);
|
|
533
|
+
this.version = current;
|
|
534
|
+
return;
|
|
210
535
|
}
|
|
211
|
-
|
|
212
|
-
|
|
536
|
+
if (current < 1) {
|
|
537
|
+
for (const stmt of SCHEMA_STATEMENTS)
|
|
538
|
+
this.db.execScript(stmt);
|
|
539
|
+
// A pre-versioning file may lack any of these columns; each ADD COLUMN
|
|
540
|
+
// throws harmlessly if the column is already present.
|
|
541
|
+
for (const mig of MIGRATIONS) {
|
|
542
|
+
try {
|
|
543
|
+
this.db.execScript(mig);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
/* column already exists */
|
|
547
|
+
}
|
|
548
|
+
}
|
|
213
549
|
}
|
|
214
|
-
|
|
550
|
+
for (const m of VERSIONED_MIGRATIONS) {
|
|
551
|
+
if (m.version <= current)
|
|
552
|
+
continue;
|
|
553
|
+
for (const stmt of m.statements) {
|
|
554
|
+
try {
|
|
555
|
+
this.db.execScript(stmt);
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
// An ADD COLUMN step is idempotent: a file whose user_version lags
|
|
559
|
+
// its real schema (e.g. reset by hand) already has the column.
|
|
560
|
+
if (!/ADD COLUMN/i.test(stmt) || !/duplicate column/i.test(String(e?.message)))
|
|
561
|
+
throw e;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (current !== SCHEMA_VERSION)
|
|
566
|
+
this.db.execScript(`PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
567
|
+
this.version = SCHEMA_VERSION;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/** The schema version of the open database. */
|
|
571
|
+
schemaVersion() {
|
|
572
|
+
return this.version;
|
|
215
573
|
}
|
|
216
574
|
/**
|
|
217
575
|
* Upsert real check-in rows (dedupe on checkin_id) in one transaction; return
|
|
@@ -221,7 +579,7 @@ export class CheckinStoreCore {
|
|
|
221
579
|
if (rows.length === 0)
|
|
222
580
|
return 0;
|
|
223
581
|
const key = username.toLowerCase();
|
|
224
|
-
|
|
582
|
+
let added = 0;
|
|
225
583
|
const sql = `
|
|
226
584
|
INSERT INTO checkins
|
|
227
585
|
(checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
|
|
@@ -231,7 +589,14 @@ export class CheckinStoreCore {
|
|
|
231
589
|
brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
|
|
232
590
|
abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
|
|
233
591
|
venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at`;
|
|
592
|
+
// "Net new" = ids this user didn't already have, read inside the write
|
|
593
|
+
// transaction so another process's concurrent inserts can't skew it. It
|
|
594
|
+
// looks up only this batch's ids; COUNT(*)-before/after scanned the user's
|
|
595
|
+
// whole history on every page, which made a long backfill quadratic.
|
|
234
596
|
this.db.transaction(() => {
|
|
597
|
+
const ids = [...new Set(rows.map((r) => r.checkin_id))];
|
|
598
|
+
const had = this.existing('checkins', 'checkin_id', key, ids);
|
|
599
|
+
added = ids.filter((id) => !had.has(id)).length;
|
|
235
600
|
for (const r of rows) {
|
|
236
601
|
this.db.run(sql, [
|
|
237
602
|
r.checkin_id, key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.beer_style,
|
|
@@ -239,14 +604,14 @@ export class CheckinStoreCore {
|
|
|
239
604
|
]);
|
|
240
605
|
}
|
|
241
606
|
});
|
|
242
|
-
return
|
|
607
|
+
return added;
|
|
243
608
|
}
|
|
244
609
|
/** Upsert distinct-beer rows (dedupe on username+bid); return the NET new count. */
|
|
245
610
|
upsertDistinctBeers(username, rows) {
|
|
246
611
|
if (rows.length === 0)
|
|
247
612
|
return 0;
|
|
248
613
|
const key = username.toLowerCase();
|
|
249
|
-
|
|
614
|
+
let added = 0;
|
|
250
615
|
const sql = `
|
|
251
616
|
INSERT INTO distinct_beers
|
|
252
617
|
(username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, had_count, first_had, last_had)
|
|
@@ -256,6 +621,9 @@ export class CheckinStoreCore {
|
|
|
256
621
|
beer_style=excluded.beer_style, abv=excluded.abv, rating=excluded.rating,
|
|
257
622
|
had_count=excluded.had_count, first_had=excluded.first_had, last_had=excluded.last_had`;
|
|
258
623
|
this.db.transaction(() => {
|
|
624
|
+
const bids = [...new Set(rows.map((r) => r.bid))];
|
|
625
|
+
const had = this.existing('distinct_beers', 'bid', key, bids);
|
|
626
|
+
added = bids.filter((b) => !had.has(b)).length;
|
|
259
627
|
for (const r of rows) {
|
|
260
628
|
this.db.run(sql, [
|
|
261
629
|
key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.beer_style, r.abv,
|
|
@@ -263,7 +631,17 @@ export class CheckinStoreCore {
|
|
|
263
631
|
]);
|
|
264
632
|
}
|
|
265
633
|
});
|
|
266
|
-
return
|
|
634
|
+
return added;
|
|
635
|
+
}
|
|
636
|
+
/** Which of `ids` already exist in `table.column` for this (lowercased) user. */
|
|
637
|
+
existing(table, column, key, ids) {
|
|
638
|
+
const found = new Set();
|
|
639
|
+
for (const part of chunks(ids)) {
|
|
640
|
+
const rows = this.db.all(`SELECT ${column} AS id FROM ${table} WHERE username = ? AND ${column} IN (${part.map(() => '?').join(', ')})`, [key, ...part]);
|
|
641
|
+
for (const r of rows)
|
|
642
|
+
found.add(Number(r.id));
|
|
643
|
+
}
|
|
644
|
+
return found;
|
|
267
645
|
}
|
|
268
646
|
/** Total cached check-in rows for a user. */
|
|
269
647
|
cachedCount(username) {
|
|
@@ -275,6 +653,197 @@ export class CheckinStoreCore {
|
|
|
275
653
|
const row = this.db.get('SELECT COUNT(*) AS n FROM distinct_beers WHERE username = ?', [username.toLowerCase()]);
|
|
276
654
|
return Number(row?.n ?? 0);
|
|
277
655
|
}
|
|
656
|
+
/**
|
|
657
|
+
* Upsert user/venues items in one transaction: the shared venue facts (the
|
|
658
|
+
* latest copy wins) and this user's visit rows. "added"/"updated" are taken
|
|
659
|
+
* against the rows already cached for just this batch's venue ids.
|
|
660
|
+
*/
|
|
661
|
+
upsertUserVenues(username, items, now = new Date().toISOString()) {
|
|
662
|
+
if (items.length === 0)
|
|
663
|
+
return { added: 0, updated: 0 };
|
|
664
|
+
const key = username.toLowerCase();
|
|
665
|
+
const venueSql = `
|
|
666
|
+
INSERT INTO venues (venue_id, name, category, address, city, state, country, lat, lng, is_verified, updated_at)
|
|
667
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
668
|
+
ON CONFLICT(venue_id) DO UPDATE SET
|
|
669
|
+
name=excluded.name, category=excluded.category, address=excluded.address, city=excluded.city,
|
|
670
|
+
state=excluded.state, country=excluded.country, lat=excluded.lat, lng=excluded.lng,
|
|
671
|
+
is_verified=excluded.is_verified, updated_at=excluded.updated_at`;
|
|
672
|
+
const visitSql = `
|
|
673
|
+
INSERT INTO user_venues
|
|
674
|
+
(username, venue_id, first_visit, first_checkin_id, recent_visit, recent_checkin_id, total_count, synced_at)
|
|
675
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
676
|
+
ON CONFLICT(username, venue_id) DO UPDATE SET
|
|
677
|
+
first_visit=excluded.first_visit, first_checkin_id=excluded.first_checkin_id,
|
|
678
|
+
recent_visit=excluded.recent_visit, recent_checkin_id=excluded.recent_checkin_id,
|
|
679
|
+
total_count=excluded.total_count, synced_at=excluded.synced_at`;
|
|
680
|
+
let added = 0;
|
|
681
|
+
let updated = 0;
|
|
682
|
+
this.db.transaction(() => {
|
|
683
|
+
const prior = new Map(this.getUserVenueVisits(key, items.map((i) => i.visit.venue_id)).map((r) => [r.venue_id, r]));
|
|
684
|
+
const seen = new Set();
|
|
685
|
+
for (const { venue: v, visit: u } of items) {
|
|
686
|
+
this.db.run(venueSql, [v.venue_id, v.name, v.category, v.address, v.city, v.state, v.country, v.lat, v.lng, v.is_verified, now]);
|
|
687
|
+
this.db.run(visitSql, [key, u.venue_id, u.first_visit, u.first_checkin_id, u.recent_visit, u.recent_checkin_id, u.total_count, now]);
|
|
688
|
+
if (seen.has(u.venue_id))
|
|
689
|
+
continue;
|
|
690
|
+
seen.add(u.venue_id);
|
|
691
|
+
const p = prior.get(u.venue_id);
|
|
692
|
+
if (!p)
|
|
693
|
+
added++;
|
|
694
|
+
else if (p.recent_visit !== u.recent_visit || p.total_count !== u.total_count)
|
|
695
|
+
updated++;
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
return { added, updated };
|
|
699
|
+
}
|
|
700
|
+
getUserVenueVisits(username, venueIds) {
|
|
701
|
+
const out = [];
|
|
702
|
+
for (const ids of chunks([...new Set(venueIds)])) {
|
|
703
|
+
if (ids.length === 0)
|
|
704
|
+
continue;
|
|
705
|
+
const rows = this.db.all(`SELECT venue_id, first_visit, first_checkin_id, recent_visit, recent_checkin_id, total_count
|
|
706
|
+
FROM user_venues WHERE username = ? AND venue_id IN (${ids.map(() => '?').join(', ')})`, [username.toLowerCase(), ...ids]);
|
|
707
|
+
for (const r of rows)
|
|
708
|
+
out.push(visitFrom(r));
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
userVenuesCount(username) {
|
|
713
|
+
return Number(this.db.get('SELECT COUNT(*) AS n FROM user_venues WHERE username = ?', [username.toLowerCase()])?.n ?? 0);
|
|
714
|
+
}
|
|
715
|
+
getVenues(venueIds) {
|
|
716
|
+
const out = [];
|
|
717
|
+
for (const c of chunks([...new Set(venueIds)])) {
|
|
718
|
+
if (c.length === 0)
|
|
719
|
+
continue;
|
|
720
|
+
const rows = this.db.all(`SELECT * FROM venues WHERE venue_id IN (${c.map(() => '?').join(', ')})`, c);
|
|
721
|
+
for (const r of rows) {
|
|
722
|
+
out.push({
|
|
723
|
+
venue_id: Number(r.venue_id),
|
|
724
|
+
name: str(r.name),
|
|
725
|
+
category: str(r.category),
|
|
726
|
+
address: str(r.address),
|
|
727
|
+
city: str(r.city),
|
|
728
|
+
state: str(r.state),
|
|
729
|
+
country: str(r.country),
|
|
730
|
+
lat: num(r.lat),
|
|
731
|
+
lng: num(r.lng),
|
|
732
|
+
is_verified: num(r.is_verified),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
getListState(username, list) {
|
|
739
|
+
const r = this.db.get('SELECT * FROM list_sync_state WHERE username = ? AND list = ?', [username.toLowerCase(), list]);
|
|
740
|
+
if (!r)
|
|
741
|
+
return undefined;
|
|
742
|
+
return {
|
|
743
|
+
username: String(r.username),
|
|
744
|
+
list: String(r.list),
|
|
745
|
+
next_offset: num(r.next_offset),
|
|
746
|
+
total_count: num(r.total_count),
|
|
747
|
+
complete: Number(r.complete) === 1,
|
|
748
|
+
last_synced_at: str(r.last_synced_at),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/** Read-merge-write in one write transaction (see {@link setState}). */
|
|
752
|
+
setListState(username, list, patch) {
|
|
753
|
+
const key = username.toLowerCase();
|
|
754
|
+
this.db.transaction(() => {
|
|
755
|
+
const cur = this.getListState(key, list);
|
|
756
|
+
const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
|
|
757
|
+
this.db.run(`INSERT INTO list_sync_state (username, list, next_offset, total_count, complete, last_synced_at)
|
|
758
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
759
|
+
ON CONFLICT(username, list) DO UPDATE SET
|
|
760
|
+
next_offset=excluded.next_offset, total_count=excluded.total_count,
|
|
761
|
+
complete=excluded.complete, last_synced_at=excluded.last_synced_at`, [
|
|
762
|
+
key,
|
|
763
|
+
list,
|
|
764
|
+
pick('next_offset', null),
|
|
765
|
+
pick('total_count', null),
|
|
766
|
+
pick('complete', false) ? 1 : 0,
|
|
767
|
+
pick('last_synced_at', null),
|
|
768
|
+
]);
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Cached venues the user has visited that match `q` (see {@link VenueQuery}),
|
|
773
|
+
* most-visited first. Name matching is deliberately loose — a brewery's
|
|
774
|
+
* taproom is its own venue (a brewery_id is not a venue_id), and Untappd keeps
|
|
775
|
+
* "… (duplicate of …)" copies — so a venue matches when the name is a literal
|
|
776
|
+
* case-insensitive substring of it, OR when every significant word of the name
|
|
777
|
+
* (ignoring "co", "company", "inc", "llc", "the") is a whole word of it.
|
|
778
|
+
*/
|
|
779
|
+
findVisitedVenues(username, q) {
|
|
780
|
+
const key = username.toLowerCase();
|
|
781
|
+
const select = `SELECT v.venue_id AS venue_id, v.name, v.category, v.address, v.city, v.state, v.country,
|
|
782
|
+
v.lat, v.lng, v.is_verified, u.first_visit, u.first_checkin_id, u.recent_visit, u.recent_checkin_id, u.total_count
|
|
783
|
+
FROM user_venues u JOIN venues v ON v.venue_id = u.venue_id WHERE u.username = ?`;
|
|
784
|
+
const found = new Map();
|
|
785
|
+
if (q.venue_ids && q.venue_ids.length > 0) {
|
|
786
|
+
for (const ids of chunks([...new Set(q.venue_ids)])) {
|
|
787
|
+
const rows = this.db.all(`${select} AND u.venue_id IN (${ids.map(() => '?').join(', ')})`, [key, ...ids]);
|
|
788
|
+
for (const r of rows)
|
|
789
|
+
found.set(Number(r.venue_id), visitedFrom(r));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const hasName = q.name !== undefined && q.name.trim() !== '';
|
|
793
|
+
const hasGeo = q.lat !== undefined && q.lng !== undefined;
|
|
794
|
+
if (hasName || hasGeo) {
|
|
795
|
+
const where = [];
|
|
796
|
+
const params = [key];
|
|
797
|
+
let tokens = [];
|
|
798
|
+
if (hasName) {
|
|
799
|
+
const name = q.name.trim();
|
|
800
|
+
tokens = significantTokens(name);
|
|
801
|
+
// Prefilter: the literal substring, or the longest significant word.
|
|
802
|
+
const longest = tokens.slice().sort((a, b) => b.length - a.length)[0];
|
|
803
|
+
if (longest) {
|
|
804
|
+
where.push(`(v.name ${LIKE_MATCH} OR v.name ${LIKE_MATCH})`);
|
|
805
|
+
params.push(`%${escapeLike(name)}%`, `%${escapeLike(longest)}%`);
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
where.push(`v.name ${LIKE_MATCH}`);
|
|
809
|
+
params.push(`%${escapeLike(name)}%`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (q.city !== undefined && q.city.trim() !== '') {
|
|
813
|
+
where.push(`v.city ${LIKE_MATCH}`);
|
|
814
|
+
params.push(`%${escapeLike(q.city.trim())}%`);
|
|
815
|
+
}
|
|
816
|
+
if (q.state !== undefined && q.state.trim() !== '') {
|
|
817
|
+
where.push(`v.state ${LIKE_MATCH}`);
|
|
818
|
+
params.push(`%${escapeLike(q.state.trim())}%`);
|
|
819
|
+
}
|
|
820
|
+
const radius = q.radius_m ?? DEFAULT_VISIT_RADIUS_M;
|
|
821
|
+
if (hasGeo) {
|
|
822
|
+
// Bounding-box prefilter; the exact great-circle check runs below.
|
|
823
|
+
const dLat = radius / 111_320;
|
|
824
|
+
const dLng = radius / (111_320 * Math.max(0.01, Math.cos((q.lat * Math.PI) / 180)));
|
|
825
|
+
where.push('v.lat BETWEEN ? AND ? AND v.lng BETWEEN ? AND ?');
|
|
826
|
+
params.push(q.lat - dLat, q.lat + dLat, q.lng - dLng, q.lng + dLng);
|
|
827
|
+
}
|
|
828
|
+
const rows = this.db.all(`${select}${where.length ? ` AND ${where.join(' AND ')}` : ''}`, params);
|
|
829
|
+
for (const r of rows) {
|
|
830
|
+
const v = visitedFrom(r);
|
|
831
|
+
if (hasName && !nameMatches(q.name.trim(), tokens, v.name))
|
|
832
|
+
continue;
|
|
833
|
+
if (hasGeo) {
|
|
834
|
+
if (v.lat === null || v.lng === null)
|
|
835
|
+
continue;
|
|
836
|
+
const d = haversineM(q.lat, q.lng, v.lat, v.lng);
|
|
837
|
+
if (d > radius)
|
|
838
|
+
continue;
|
|
839
|
+
v.distance_m = Math.round(d);
|
|
840
|
+
}
|
|
841
|
+
const prior = found.get(v.venue_id);
|
|
842
|
+
found.set(v.venue_id, prior && v.distance_m === undefined ? prior : v);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return [...found.values()].sort((a, b) => (b.total_count ?? 0) - (a.total_count ?? 0) || (b.recent_visit ?? '').localeCompare(a.recent_visit ?? '') || a.venue_id - b.venue_id);
|
|
846
|
+
}
|
|
278
847
|
/** Distinct bids appearing in the checkins table. */
|
|
279
848
|
checkinsBeerCount(username) {
|
|
280
849
|
const row = this.db.get('SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL', [username.toLowerCase()]);
|
|
@@ -312,6 +881,11 @@ export class CheckinStoreCore {
|
|
|
312
881
|
* merge could not do that.
|
|
313
882
|
*/
|
|
314
883
|
setState(username, patch) {
|
|
884
|
+
// Read-merge-write in one write transaction, so a second process's patch
|
|
885
|
+
// can't land between the read and the write and be silently reverted.
|
|
886
|
+
this.db.transaction(() => this.mergeState(username, patch));
|
|
887
|
+
}
|
|
888
|
+
mergeState(username, patch) {
|
|
315
889
|
const key = username.toLowerCase();
|
|
316
890
|
const cur = this.getState(key);
|
|
317
891
|
const pick = (k, fallback) => k in patch ? patch[k] : (cur?.[k] ?? fallback);
|
|
@@ -421,6 +995,46 @@ export class CheckinStoreCore {
|
|
|
421
995
|
matches,
|
|
422
996
|
};
|
|
423
997
|
}
|
|
998
|
+
/**
|
|
999
|
+
* {@link hasHad} for many bids at once, in two grouped queries per chunk
|
|
1000
|
+
* instead of ~4 per bid. Same merge rules: a hit in either source counts,
|
|
1001
|
+
* `count` prefers user/beers' authoritative had_count, dates and ratings take
|
|
1002
|
+
* the max across both. No per-check-in `matches` — callers of the batch form
|
|
1003
|
+
* only want the verdict. Every requested bid gets an entry.
|
|
1004
|
+
*/
|
|
1005
|
+
hasHadMany(username, bids) {
|
|
1006
|
+
const key = username.toLowerCase();
|
|
1007
|
+
const out = new Map();
|
|
1008
|
+
const unique = [...new Set(bids)];
|
|
1009
|
+
for (const bid of unique)
|
|
1010
|
+
out.set(bid, { had: false, count: 0, last_date: null, best_rating: null, sources: [] });
|
|
1011
|
+
for (const part of chunks(unique)) {
|
|
1012
|
+
const ph = part.map(() => '?').join(', ');
|
|
1013
|
+
for (const r of this.db.all(`SELECT bid, COUNT(*) AS c, MAX(rating) AS best, MAX(created_at) AS last
|
|
1014
|
+
FROM checkins WHERE username = ? AND bid IN (${ph}) GROUP BY bid`, [key, ...part])) {
|
|
1015
|
+
const v = out.get(Number(r.bid));
|
|
1016
|
+
v.had = true;
|
|
1017
|
+
v.count = Number(r.c);
|
|
1018
|
+
v.best_rating = num(r.best);
|
|
1019
|
+
v.last_date = str(r.last);
|
|
1020
|
+
v.sources.push('checkins');
|
|
1021
|
+
}
|
|
1022
|
+
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])) {
|
|
1023
|
+
const v = out.get(Number(r.bid));
|
|
1024
|
+
v.had = true;
|
|
1025
|
+
// Prefer the user/beers count; else keep the check-in-derived count; else 1.
|
|
1026
|
+
v.count = num(r.had_count) ?? (v.count || 1);
|
|
1027
|
+
const rating = num(r.rating);
|
|
1028
|
+
if (rating !== null && (v.best_rating === null || rating > v.best_rating))
|
|
1029
|
+
v.best_rating = rating;
|
|
1030
|
+
const last = str(r.last_had) ?? str(r.first_had);
|
|
1031
|
+
if (last && (v.last_date === null || last > v.last_date))
|
|
1032
|
+
v.last_date = last;
|
|
1033
|
+
v.sources.push('beers');
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return out;
|
|
1037
|
+
}
|
|
424
1038
|
query(username, filters) {
|
|
425
1039
|
const where = ['username = ?'];
|
|
426
1040
|
const params = [username.toLowerCase()];
|
|
@@ -466,22 +1080,635 @@ export class CheckinStoreCore {
|
|
|
466
1080
|
const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
|
|
467
1081
|
return this.db.all(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`, [...params, limit]);
|
|
468
1082
|
}
|
|
469
|
-
/**
|
|
1083
|
+
/**
|
|
1084
|
+
* Delete one user's cached rows (case-insensitive) from every per-user table
|
|
1085
|
+
* in a single transaction. Shared tables (beer_meta, venues, brewery lists,
|
|
1086
|
+
* response cache, the API call log) hold no personal history and are kept.
|
|
1087
|
+
*/
|
|
470
1088
|
forgetUser(username) {
|
|
471
1089
|
const key = username.toLowerCase();
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1090
|
+
const removed = {};
|
|
1091
|
+
this.db.transaction(() => {
|
|
1092
|
+
for (const table of PER_USER_TABLES) {
|
|
1093
|
+
removed[table] = Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
|
|
1094
|
+
this.db.run(`DELETE FROM ${table} WHERE username = ?`, [key]);
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
return removed;
|
|
1098
|
+
}
|
|
1099
|
+
/** Append API call observations to the shared log, pruning entries past retention. */
|
|
1100
|
+
recordApiCalls(rows) {
|
|
1101
|
+
if (rows.length === 0)
|
|
1102
|
+
return;
|
|
1103
|
+
this.db.transaction(() => {
|
|
1104
|
+
for (const r of rows) {
|
|
1105
|
+
this.db.run('INSERT INTO api_calls (at, status, remaining, limit_hdr, reset_at, retry_after_s) VALUES (?, ?, ?, ?, ?, ?)', [r.at, r.status, r.remaining, r.limit, r.reset_at, r.retry_after_s]);
|
|
1106
|
+
}
|
|
1107
|
+
this.db.run('DELETE FROM api_calls WHERE at < ?', [Date.now() - API_CALL_LOG_RETENTION_MS]);
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
/** Logged API calls at or after `sinceMs`, oldest first. */
|
|
1111
|
+
recentApiCalls(sinceMs) {
|
|
1112
|
+
return this.db
|
|
1113
|
+
.all('SELECT * FROM api_calls WHERE at >= ? ORDER BY at, rowid', [sinceMs])
|
|
1114
|
+
.map((r) => ({
|
|
1115
|
+
at: Number(r.at),
|
|
1116
|
+
status: Number(r.status),
|
|
1117
|
+
remaining: num(r.remaining),
|
|
1118
|
+
limit: num(r.limit_hdr),
|
|
1119
|
+
reset_at: num(r.reset_at),
|
|
1120
|
+
retry_after_s: num(r.retry_after_s),
|
|
1121
|
+
}));
|
|
1122
|
+
}
|
|
1123
|
+
getDistinctBeers(username, bids) {
|
|
1124
|
+
if (bids.length === 0)
|
|
1125
|
+
return [];
|
|
1126
|
+
const placeholders = bids.map(() => '?').join(', ');
|
|
1127
|
+
return this.db.all(`SELECT * FROM distinct_beers WHERE username = ? AND bid IN (${placeholders})`, [username.toLowerCase(), ...bids]);
|
|
1128
|
+
}
|
|
1129
|
+
getResolutions(keys) {
|
|
1130
|
+
if (keys.length === 0)
|
|
1131
|
+
return [];
|
|
1132
|
+
const placeholders = keys.map(() => '?').join(', ');
|
|
1133
|
+
return this.db.all(`SELECT * FROM item_resolutions WHERE key IN (${placeholders})`, keys);
|
|
1134
|
+
}
|
|
1135
|
+
upsertResolutions(rows) {
|
|
1136
|
+
if (rows.length === 0)
|
|
1137
|
+
return;
|
|
1138
|
+
const sql = `
|
|
1139
|
+
INSERT INTO item_resolutions (key, item, status, bid, data, resolved_at)
|
|
1140
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1141
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
1142
|
+
item=excluded.item, status=excluded.status, bid=excluded.bid,
|
|
1143
|
+
data=excluded.data, resolved_at=excluded.resolved_at`;
|
|
1144
|
+
this.db.transaction(() => {
|
|
1145
|
+
for (const r of rows)
|
|
1146
|
+
this.db.run(sql, [r.key, r.item, r.status, r.bid, r.data, r.resolved_at]);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
stats() {
|
|
1150
|
+
const n = (sql) => Number(this.db.get(sql, [])?.n ?? 0);
|
|
1151
|
+
const names = this.db
|
|
1152
|
+
.all(`SELECT username FROM sync_state
|
|
1153
|
+
UNION SELECT DISTINCT username FROM distinct_beers
|
|
1154
|
+
UNION SELECT DISTINCT username FROM checkins
|
|
1155
|
+
UNION SELECT DISTINCT username FROM user_venues
|
|
1156
|
+
UNION SELECT username FROM list_sync_state WHERE list IN ('venues', 'wishlist', 'brewery_likes')
|
|
1157
|
+
UNION SELECT DISTINCT username FROM wishlist
|
|
1158
|
+
UNION SELECT DISTINCT username FROM brewery_likes
|
|
1159
|
+
ORDER BY username`, [])
|
|
1160
|
+
.map((r) => String(r.username));
|
|
1161
|
+
const users = names.map((username) => {
|
|
1162
|
+
const st = this.getState(username);
|
|
1163
|
+
return {
|
|
1164
|
+
username,
|
|
1165
|
+
cached_distinct_beers: this.distinctBeersCount(username),
|
|
1166
|
+
total_distinct_beers: st?.beers_total ?? null,
|
|
1167
|
+
cached_checkins: this.cachedCount(username),
|
|
1168
|
+
coverage_complete: (st?.beers_complete ?? false) || (st?.backfill_complete ?? false),
|
|
1169
|
+
beers_offset: st?.beers_offset ?? null,
|
|
1170
|
+
last_synced_at: st?.last_synced_at ?? null,
|
|
1171
|
+
venues: this.venueCoverage(username),
|
|
1172
|
+
wishlist: this.wishlistCoverage(username),
|
|
1173
|
+
brewery_likes: this.breweryLikesCoverage(username),
|
|
1174
|
+
};
|
|
1175
|
+
});
|
|
1176
|
+
return {
|
|
1177
|
+
schema_version: this.version,
|
|
1178
|
+
users,
|
|
1179
|
+
resolution_cache_entries: n('SELECT COUNT(*) AS n FROM item_resolutions'),
|
|
1180
|
+
beer_meta_entries: n('SELECT COUNT(*) AS n FROM beer_meta'),
|
|
1181
|
+
venue_entries: n('SELECT COUNT(*) AS n FROM venues'),
|
|
1182
|
+
brewery_lists: n('SELECT COUNT(*) AS n FROM brewery_beer_lists'),
|
|
1183
|
+
response_cache_entries: n('SELECT COUNT(*) AS n FROM response_cache'),
|
|
477
1184
|
};
|
|
1185
|
+
}
|
|
1186
|
+
/** The user's user/venues coverage, or null if their venue history was never synced. */
|
|
1187
|
+
venueCoverage(username) {
|
|
1188
|
+
// Literal keys: VENUES_LIST / VENUES_REFRESH_LIST in sync-venues.ts.
|
|
1189
|
+
const st = this.getListState(username, 'venues');
|
|
1190
|
+
const cached = this.userVenuesCount(username);
|
|
1191
|
+
if (!st && cached === 0)
|
|
1192
|
+
return null;
|
|
1193
|
+
const refresh = this.getListState(username, 'venues_refresh');
|
|
1194
|
+
const refreshing = (st?.complete ?? false) && !!refresh && !refresh.complete;
|
|
1195
|
+
const total = st?.total_count ?? null;
|
|
1196
|
+
const complete = (st?.complete ?? false) && !refreshing;
|
|
1197
|
+
return {
|
|
1198
|
+
cached_venues: cached,
|
|
1199
|
+
total_venues: total,
|
|
1200
|
+
complete,
|
|
1201
|
+
refresh_in_progress: refreshing,
|
|
1202
|
+
percent: total && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null,
|
|
1203
|
+
next_offset: refreshing ? refresh.next_offset : (st?.next_offset ?? null),
|
|
1204
|
+
last_synced_at: st?.last_synced_at ?? null,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
getBreweryBeerList(breweryId) {
|
|
1208
|
+
const h = this.db.get('SELECT * FROM brewery_beer_lists WHERE brewery_id = ?', [breweryId]);
|
|
1209
|
+
if (!h)
|
|
1210
|
+
return undefined;
|
|
1211
|
+
const beers = this.db
|
|
1212
|
+
.all(`SELECT * FROM brewery_beers WHERE brewery_id = ?
|
|
1213
|
+
ORDER BY popularity_rank IS NULL, popularity_rank, checkin_count IS NULL, checkin_count DESC, bid`, [breweryId])
|
|
1214
|
+
.map((r) => ({
|
|
1215
|
+
brewery_id: Number(r.brewery_id),
|
|
1216
|
+
bid: Number(r.bid),
|
|
1217
|
+
beer_name: str(r.beer_name),
|
|
1218
|
+
style: str(r.style),
|
|
1219
|
+
abv: num(r.abv),
|
|
1220
|
+
ibu: num(r.ibu),
|
|
1221
|
+
rating_score: num(r.rating_score),
|
|
1222
|
+
rating_count: num(r.rating_count),
|
|
1223
|
+
checkin_count: num(r.checkin_count),
|
|
1224
|
+
in_production: r.in_production === null || r.in_production === undefined ? null : Number(r.in_production) !== 0,
|
|
1225
|
+
popularity_rank: num(r.popularity_rank),
|
|
1226
|
+
fetched_at: String(r.fetched_at),
|
|
1227
|
+
}));
|
|
1228
|
+
return {
|
|
1229
|
+
brewery_id: breweryId,
|
|
1230
|
+
brewery_name: str(h.brewery_name),
|
|
1231
|
+
total_count: num(h.total_count),
|
|
1232
|
+
ranked_count: Number(h.ranked_count ?? 0),
|
|
1233
|
+
fetched_at: String(h.fetched_at),
|
|
1234
|
+
beers,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Store one brewery/beer_list page in one transaction. `restart` (a stale or
|
|
1239
|
+
* refreshed list) drops the old rows and restarts the TTL clock; otherwise the
|
|
1240
|
+
* page is merged into the current snapshot, which keeps its fetched_at. A page
|
|
1241
|
+
* fetched in popularity order at an offset inside the ranked prefix extends it.
|
|
1242
|
+
*/
|
|
1243
|
+
saveBreweryBeerPage(page) {
|
|
478
1244
|
this.db.transaction(() => {
|
|
479
|
-
this.db.
|
|
480
|
-
|
|
481
|
-
|
|
1245
|
+
const cur = this.db.get('SELECT * FROM brewery_beer_lists WHERE brewery_id = ?', [page.brewery_id]);
|
|
1246
|
+
const fresh = page.restart || !cur;
|
|
1247
|
+
if (page.restart)
|
|
1248
|
+
this.db.run('DELETE FROM brewery_beers WHERE brewery_id = ?', [page.brewery_id]);
|
|
1249
|
+
let ranked = fresh ? 0 : Number(cur.ranked_count ?? 0);
|
|
1250
|
+
if (page.ranked_offset !== null && page.ranked_offset <= ranked) {
|
|
1251
|
+
ranked = Math.max(ranked, page.ranked_offset + page.item_count);
|
|
1252
|
+
}
|
|
1253
|
+
this.db.run(`INSERT INTO brewery_beer_lists (brewery_id, brewery_name, total_count, ranked_count, fetched_at)
|
|
1254
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1255
|
+
ON CONFLICT(brewery_id) DO UPDATE SET
|
|
1256
|
+
brewery_name = COALESCE(excluded.brewery_name, brewery_beer_lists.brewery_name),
|
|
1257
|
+
total_count = COALESCE(excluded.total_count, brewery_beer_lists.total_count),
|
|
1258
|
+
ranked_count = excluded.ranked_count,
|
|
1259
|
+
fetched_at = excluded.fetched_at`, [
|
|
1260
|
+
page.brewery_id,
|
|
1261
|
+
page.brewery_name,
|
|
1262
|
+
page.total_count,
|
|
1263
|
+
ranked,
|
|
1264
|
+
fresh ? page.fetched_at : String(cur.fetched_at),
|
|
1265
|
+
]);
|
|
1266
|
+
for (const r of page.rows) {
|
|
1267
|
+
this.db.run(`INSERT INTO brewery_beers (brewery_id, bid, beer_name, style, abv, ibu, rating_score, rating_count,
|
|
1268
|
+
checkin_count, in_production, popularity_rank, fetched_at)
|
|
1269
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1270
|
+
ON CONFLICT(brewery_id, bid) DO UPDATE SET
|
|
1271
|
+
beer_name = excluded.beer_name, style = excluded.style, abv = excluded.abv, ibu = excluded.ibu,
|
|
1272
|
+
rating_score = excluded.rating_score, rating_count = excluded.rating_count,
|
|
1273
|
+
checkin_count = excluded.checkin_count, in_production = excluded.in_production,
|
|
1274
|
+
popularity_rank = COALESCE(excluded.popularity_rank, brewery_beers.popularity_rank),
|
|
1275
|
+
fetched_at = excluded.fetched_at`, [
|
|
1276
|
+
r.brewery_id,
|
|
1277
|
+
r.bid,
|
|
1278
|
+
r.beer_name,
|
|
1279
|
+
r.style,
|
|
1280
|
+
r.abv,
|
|
1281
|
+
r.ibu,
|
|
1282
|
+
r.rating_score,
|
|
1283
|
+
r.rating_count,
|
|
1284
|
+
r.checkin_count,
|
|
1285
|
+
r.in_production === null ? null : r.in_production ? 1 : 0,
|
|
1286
|
+
r.popularity_rank,
|
|
1287
|
+
r.fetched_at,
|
|
1288
|
+
]);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
getCachedResponse(key) {
|
|
1293
|
+
const r = this.db.get('SELECT * FROM response_cache WHERE key = ?', [key]);
|
|
1294
|
+
if (!r)
|
|
1295
|
+
return undefined;
|
|
1296
|
+
let data;
|
|
1297
|
+
try {
|
|
1298
|
+
data = JSON.parse(String(r.data));
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
return undefined;
|
|
1302
|
+
}
|
|
1303
|
+
return { key, kind: String(r.kind), data, fetched_at: String(r.fetched_at) };
|
|
1304
|
+
}
|
|
1305
|
+
putCachedResponse(key, kind, data, fetchedAt) {
|
|
1306
|
+
this.db.transaction(() => {
|
|
1307
|
+
this.db.run(`INSERT INTO response_cache (key, kind, data, fetched_at) VALUES (?, ?, ?, ?)
|
|
1308
|
+
ON CONFLICT(key) DO UPDATE SET kind = excluded.kind, data = excluded.data, fetched_at = excluded.fetched_at`, [key, kind, JSON.stringify(data ?? null), fetchedAt]);
|
|
1309
|
+
// These responses hold other people's recent check-ins; keep them only
|
|
1310
|
+
// as long as any TTL could still serve them.
|
|
1311
|
+
const cutoff = new Date(Date.parse(fetchedAt) - RESPONSE_CACHE_RETENTION_MS).toISOString();
|
|
1312
|
+
this.db.run('DELETE FROM response_cache WHERE fetched_at < ?', [cutoff]);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
upsertWishlist(username, rows, passStamp) {
|
|
1316
|
+
if (rows.length === 0)
|
|
1317
|
+
return 0;
|
|
1318
|
+
const key = username.toLowerCase();
|
|
1319
|
+
let added = 0;
|
|
1320
|
+
this.db.transaction(() => {
|
|
1321
|
+
const bids = rows.map((r) => r.bid);
|
|
1322
|
+
const known = new Set();
|
|
1323
|
+
for (const c of chunks(bids)) {
|
|
1324
|
+
for (const r of this.db.all(`SELECT bid FROM wishlist WHERE username = ? AND bid IN (${c.map(() => '?').join(', ')})`, [key, ...c])) {
|
|
1325
|
+
known.add(Number(r.bid));
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
for (const r of rows) {
|
|
1329
|
+
if (!known.has(r.bid)) {
|
|
1330
|
+
added++;
|
|
1331
|
+
known.add(r.bid);
|
|
1332
|
+
}
|
|
1333
|
+
this.db.run(`INSERT INTO wishlist (username, bid, beer_name, brewery_id, brewery_name, style, abv, added_at, synced_at)
|
|
1334
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1335
|
+
ON CONFLICT(username, bid) DO UPDATE SET
|
|
1336
|
+
beer_name = excluded.beer_name, brewery_id = excluded.brewery_id, brewery_name = excluded.brewery_name,
|
|
1337
|
+
style = excluded.style, abv = excluded.abv, added_at = excluded.added_at, synced_at = excluded.synced_at`, [key, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.style, r.abv, r.added_at, passStamp]);
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
return added;
|
|
1341
|
+
}
|
|
1342
|
+
pruneWishlist(username, passStamp) {
|
|
1343
|
+
let removed = 0;
|
|
1344
|
+
this.db.transaction(() => {
|
|
1345
|
+
const key = username.toLowerCase();
|
|
1346
|
+
removed = Number(this.db.get('SELECT COUNT(*) AS n FROM wishlist WHERE username = ? AND synced_at <> ?', [key, passStamp])?.n ?? 0);
|
|
1347
|
+
this.db.run('DELETE FROM wishlist WHERE username = ? AND synced_at <> ?', [key, passStamp]);
|
|
1348
|
+
});
|
|
1349
|
+
return removed;
|
|
1350
|
+
}
|
|
1351
|
+
wishlistCount(username) {
|
|
1352
|
+
return Number(this.db.get('SELECT COUNT(*) AS n FROM wishlist WHERE username = ?', [username.toLowerCase()])?.n ?? 0);
|
|
1353
|
+
}
|
|
1354
|
+
getWishlist(username, bids) {
|
|
1355
|
+
const key = username.toLowerCase();
|
|
1356
|
+
const map = (r) => ({
|
|
1357
|
+
bid: Number(r.bid),
|
|
1358
|
+
beer_name: str(r.beer_name),
|
|
1359
|
+
brewery_id: num(r.brewery_id),
|
|
1360
|
+
brewery_name: str(r.brewery_name),
|
|
1361
|
+
style: str(r.style),
|
|
1362
|
+
abv: num(r.abv),
|
|
1363
|
+
added_at: str(r.added_at),
|
|
1364
|
+
});
|
|
1365
|
+
if (bids === undefined) {
|
|
1366
|
+
return this.db.all('SELECT * FROM wishlist WHERE username = ? ORDER BY added_at DESC, bid', [key]).map(map);
|
|
1367
|
+
}
|
|
1368
|
+
const out = [];
|
|
1369
|
+
for (const c of chunks(bids)) {
|
|
1370
|
+
if (c.length === 0)
|
|
1371
|
+
continue;
|
|
1372
|
+
out.push(...this.db
|
|
1373
|
+
.all(`SELECT * FROM wishlist WHERE username = ? AND bid IN (${c.map(() => '?').join(', ')})`, [key, ...c])
|
|
1374
|
+
.map(map));
|
|
1375
|
+
}
|
|
1376
|
+
return out;
|
|
1377
|
+
}
|
|
1378
|
+
/** The user's wishlist mirror coverage, or null if it was never synced. */
|
|
1379
|
+
wishlistCoverage(username) {
|
|
1380
|
+
// Literal key: WISHLIST_LIST in sync-wishlist.ts.
|
|
1381
|
+
const st = this.getListState(username, 'wishlist');
|
|
1382
|
+
const cached = this.wishlistCount(username);
|
|
1383
|
+
if (!st && cached === 0)
|
|
1384
|
+
return null;
|
|
1385
|
+
return {
|
|
1386
|
+
cached,
|
|
1387
|
+
total: st?.total_count ?? null,
|
|
1388
|
+
complete: st?.complete ?? false,
|
|
1389
|
+
next_offset: st?.next_offset ?? null,
|
|
1390
|
+
last_synced_at: st?.last_synced_at ?? null,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
upsertBreweryLikes(username, rows, passStamp) {
|
|
1394
|
+
if (rows.length === 0)
|
|
1395
|
+
return 0;
|
|
1396
|
+
const key = username.toLowerCase();
|
|
1397
|
+
let added = 0;
|
|
1398
|
+
this.db.transaction(() => {
|
|
1399
|
+
const known = new Set();
|
|
1400
|
+
for (const c of chunks(rows.map((r) => r.brewery_id))) {
|
|
1401
|
+
for (const r of this.db.all(`SELECT brewery_id FROM brewery_likes WHERE username = ? AND brewery_id IN (${c.map(() => '?').join(', ')})`, [key, ...c])) {
|
|
1402
|
+
known.add(Number(r.brewery_id));
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
for (const r of rows) {
|
|
1406
|
+
if (!known.has(r.brewery_id)) {
|
|
1407
|
+
added++;
|
|
1408
|
+
known.add(r.brewery_id);
|
|
1409
|
+
}
|
|
1410
|
+
this.db.run(`INSERT INTO brewery_likes (username, brewery_id, brewery_name, brewery_type, country, city, state, lat, lng, liked_at, synced_at)
|
|
1411
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1412
|
+
ON CONFLICT(username, brewery_id) DO UPDATE SET
|
|
1413
|
+
brewery_name = excluded.brewery_name, brewery_type = excluded.brewery_type, country = excluded.country,
|
|
1414
|
+
city = excluded.city, state = excluded.state, lat = excluded.lat, lng = excluded.lng,
|
|
1415
|
+
liked_at = excluded.liked_at, synced_at = excluded.synced_at`, [key, r.brewery_id, r.brewery_name, r.brewery_type, r.country, r.city, r.state, r.lat, r.lng, r.liked_at, passStamp]);
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
return added;
|
|
1419
|
+
}
|
|
1420
|
+
pruneBreweryLikes(username, passStamp) {
|
|
1421
|
+
let removed = 0;
|
|
1422
|
+
this.db.transaction(() => {
|
|
1423
|
+
const key = username.toLowerCase();
|
|
1424
|
+
removed = Number(this.db.get('SELECT COUNT(*) AS n FROM brewery_likes WHERE username = ? AND synced_at <> ?', [key, passStamp])?.n ?? 0);
|
|
1425
|
+
this.db.run('DELETE FROM brewery_likes WHERE username = ? AND synced_at <> ?', [key, passStamp]);
|
|
1426
|
+
});
|
|
1427
|
+
return removed;
|
|
1428
|
+
}
|
|
1429
|
+
breweryLikesCount(username) {
|
|
1430
|
+
return Number(this.db.get('SELECT COUNT(*) AS n FROM brewery_likes WHERE username = ?', [username.toLowerCase()])?.n ?? 0);
|
|
1431
|
+
}
|
|
1432
|
+
getBreweryLikes(username, breweryIds) {
|
|
1433
|
+
const key = username.toLowerCase();
|
|
1434
|
+
const map = (r) => ({
|
|
1435
|
+
brewery_id: Number(r.brewery_id),
|
|
1436
|
+
brewery_name: str(r.brewery_name),
|
|
1437
|
+
brewery_type: str(r.brewery_type),
|
|
1438
|
+
country: str(r.country),
|
|
1439
|
+
city: str(r.city),
|
|
1440
|
+
state: str(r.state),
|
|
1441
|
+
lat: num(r.lat),
|
|
1442
|
+
lng: num(r.lng),
|
|
1443
|
+
liked_at: str(r.liked_at),
|
|
1444
|
+
});
|
|
1445
|
+
const order = 'ORDER BY liked_at IS NULL, liked_at DESC, brewery_id';
|
|
1446
|
+
if (breweryIds === undefined) {
|
|
1447
|
+
return this.db.all(`SELECT * FROM brewery_likes WHERE username = ? ${order}`, [key]).map(map);
|
|
1448
|
+
}
|
|
1449
|
+
const out = [];
|
|
1450
|
+
for (const c of chunks(breweryIds)) {
|
|
1451
|
+
if (c.length === 0)
|
|
1452
|
+
continue;
|
|
1453
|
+
out.push(...this.db
|
|
1454
|
+
.all(`SELECT * FROM brewery_likes WHERE username = ? AND brewery_id IN (${c.map(() => '?').join(', ')}) ${order}`, [key, ...c])
|
|
1455
|
+
.map(map));
|
|
1456
|
+
}
|
|
1457
|
+
return out;
|
|
1458
|
+
}
|
|
1459
|
+
/** The user's Untappd brewery-likes coverage, or null if it was never synced. */
|
|
1460
|
+
breweryLikesCoverage(username) {
|
|
1461
|
+
// Literal key: BREWERY_LIKES_LIST in sync-brewery-likes.ts.
|
|
1462
|
+
const st = this.getListState(username, 'brewery_likes');
|
|
1463
|
+
const cached = this.breweryLikesCount(username);
|
|
1464
|
+
if (!st && cached === 0)
|
|
1465
|
+
return null;
|
|
1466
|
+
const total = st?.total_count ?? null;
|
|
1467
|
+
const complete = st?.complete ?? false;
|
|
1468
|
+
return {
|
|
1469
|
+
cached,
|
|
1470
|
+
total,
|
|
1471
|
+
complete,
|
|
1472
|
+
percent: total && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null,
|
|
1473
|
+
next_offset: st?.next_offset ?? null,
|
|
1474
|
+
last_synced_at: st?.last_synced_at ?? null,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
venueDrinkCandidates(username, opts = {}) {
|
|
1478
|
+
const key = username.toLowerCase();
|
|
1479
|
+
const which = opts.which ?? 'both';
|
|
1480
|
+
const venueFilter = (ids) => (ids.length ? ` AND u.venue_id IN (${ids.map(() => '?').join(', ')})` : '');
|
|
1481
|
+
const parts = [];
|
|
1482
|
+
if (which !== 'recent')
|
|
1483
|
+
parts.push({ col: 'first_checkin_id', kind: 'first' });
|
|
1484
|
+
if (which !== 'first')
|
|
1485
|
+
parts.push({ col: 'recent_checkin_id', kind: 'recent' });
|
|
1486
|
+
const idSets = opts.venueIds && opts.venueIds.length > 0 ? chunks([...new Set(opts.venueIds)]) : [[]];
|
|
1487
|
+
const rows = [];
|
|
1488
|
+
for (const ids of idSets) {
|
|
1489
|
+
for (const { col, kind } of parts) {
|
|
1490
|
+
rows.push(...this.db
|
|
1491
|
+
.all(`SELECT u.venue_id AS venue_id, u.${col} AS checkin_id, u.total_count AS total_count, u.recent_visit AS recent_visit
|
|
1492
|
+
FROM user_venues u
|
|
1493
|
+
WHERE u.username = ? AND u.${col} IS NOT NULL${venueFilter(ids)}
|
|
1494
|
+
AND NOT EXISTS (SELECT 1 FROM venue_drinks d WHERE d.username = u.username AND d.checkin_id = u.${col})`, [key, ...ids])
|
|
1495
|
+
.map((r) => ({ ...r, kind })));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
rows.sort((a, b) => Number(b.total_count ?? 0) - Number(a.total_count ?? 0) ||
|
|
1499
|
+
String(b.recent_visit ?? '').localeCompare(String(a.recent_visit ?? '')) ||
|
|
1500
|
+
Number(a.venue_id) - Number(b.venue_id) ||
|
|
1501
|
+
(a.kind === b.kind ? 0 : a.kind === 'first' ? -1 : 1));
|
|
1502
|
+
const seen = new Set();
|
|
1503
|
+
const out = [];
|
|
1504
|
+
for (const r of rows) {
|
|
1505
|
+
const id = Number(r.checkin_id);
|
|
1506
|
+
if (seen.has(id))
|
|
1507
|
+
continue;
|
|
1508
|
+
seen.add(id);
|
|
1509
|
+
out.push({ venue_id: Number(r.venue_id), checkin_id: id, kind: r.kind });
|
|
1510
|
+
}
|
|
1511
|
+
return out;
|
|
1512
|
+
}
|
|
1513
|
+
upsertVenueDrinks(username, rows, fetchedAt) {
|
|
1514
|
+
if (rows.length === 0)
|
|
1515
|
+
return;
|
|
1516
|
+
const key = username.toLowerCase();
|
|
1517
|
+
const sql = `
|
|
1518
|
+
INSERT INTO venue_drinks (username, venue_id, checkin_id, bid, beer_name, brewery_id, brewery_name, created_at, fetched_at)
|
|
1519
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1520
|
+
ON CONFLICT(username, checkin_id) DO UPDATE SET
|
|
1521
|
+
venue_id=excluded.venue_id, bid=excluded.bid, beer_name=excluded.beer_name, brewery_id=excluded.brewery_id,
|
|
1522
|
+
brewery_name=excluded.brewery_name, created_at=excluded.created_at, fetched_at=excluded.fetched_at`;
|
|
1523
|
+
this.db.transaction(() => {
|
|
1524
|
+
for (const r of rows) {
|
|
1525
|
+
this.db.run(sql, [key, r.venue_id, r.checkin_id, r.bid, r.beer_name, r.brewery_id, r.brewery_name, r.created_at, fetchedAt]);
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
getVenueDrinks(username, venueIds) {
|
|
1530
|
+
const key = username.toLowerCase();
|
|
1531
|
+
const out = [];
|
|
1532
|
+
for (const ids of chunks([...new Set(venueIds)])) {
|
|
1533
|
+
if (ids.length === 0)
|
|
1534
|
+
continue;
|
|
1535
|
+
const rows = this.db.all(`SELECT venue_id, checkin_id, bid, beer_name, brewery_id, brewery_name, created_at FROM venue_drinks
|
|
1536
|
+
WHERE username = ? AND venue_id IN (${ids.map(() => '?').join(', ')})
|
|
1537
|
+
AND NOT (bid IS NULL AND beer_name IS NULL)`, [key, ...ids]);
|
|
1538
|
+
for (const r of rows) {
|
|
1539
|
+
out.push({
|
|
1540
|
+
venue_id: Number(r.venue_id),
|
|
1541
|
+
checkin_id: Number(r.checkin_id),
|
|
1542
|
+
bid: num(r.bid),
|
|
1543
|
+
beer_name: str(r.beer_name),
|
|
1544
|
+
brewery_id: num(r.brewery_id),
|
|
1545
|
+
brewery_name: str(r.brewery_name),
|
|
1546
|
+
created_at: str(r.created_at),
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return out.sort((a, b) => String(b.created_at ?? '').localeCompare(String(a.created_at ?? '')) || b.checkin_id - a.checkin_id);
|
|
1551
|
+
}
|
|
1552
|
+
addFavorite(username, kind, id, note, createdAt) {
|
|
1553
|
+
const key = username.toLowerCase();
|
|
1554
|
+
let out = { added: false, note: null, created_at: createdAt };
|
|
1555
|
+
this.db.transaction(() => {
|
|
1556
|
+
const cur = this.db.get('SELECT note, created_at FROM favorites WHERE username = ? AND kind = ? AND id = ?', [key, kind, id]);
|
|
1557
|
+
if (!cur) {
|
|
1558
|
+
const n = note ?? null;
|
|
1559
|
+
this.db.run('INSERT INTO favorites (username, kind, id, note, created_at) VALUES (?, ?, ?, ?, ?)', [key, kind, id, n, createdAt]);
|
|
1560
|
+
out = { added: true, note: n, created_at: createdAt };
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
const n = note === undefined ? str(cur.note) : note;
|
|
1564
|
+
this.db.run('UPDATE favorites SET note = ? WHERE username = ? AND kind = ? AND id = ?', [n, key, kind, id]);
|
|
1565
|
+
out = { added: false, note: n, created_at: String(cur.created_at) };
|
|
1566
|
+
});
|
|
1567
|
+
return out;
|
|
1568
|
+
}
|
|
1569
|
+
removeFavorite(username, kind, id) {
|
|
1570
|
+
let removed = false;
|
|
1571
|
+
this.db.transaction(() => {
|
|
1572
|
+
const key = username.toLowerCase();
|
|
1573
|
+
removed = !!this.db.get('SELECT 1 AS x FROM favorites WHERE username = ? AND kind = ? AND id = ?', [key, kind, id]);
|
|
1574
|
+
if (removed)
|
|
1575
|
+
this.db.run('DELETE FROM favorites WHERE username = ? AND kind = ? AND id = ?', [key, kind, id]);
|
|
482
1576
|
});
|
|
483
1577
|
return removed;
|
|
484
1578
|
}
|
|
1579
|
+
getManualFavorites(username, opts = {}) {
|
|
1580
|
+
const key = username.toLowerCase();
|
|
1581
|
+
// Best-effort display name from whatever the cache already holds.
|
|
1582
|
+
const nameSql = `CASE f.kind
|
|
1583
|
+
WHEN 'beer' THEN COALESCE(
|
|
1584
|
+
(SELECT beer_name FROM distinct_beers d WHERE d.username = f.username AND d.bid = f.id),
|
|
1585
|
+
(SELECT name FROM beer_meta m WHERE m.bid = f.id),
|
|
1586
|
+
(SELECT beer_name FROM checkins c WHERE c.username = f.username AND c.bid = f.id LIMIT 1),
|
|
1587
|
+
(SELECT beer_name FROM wishlist w WHERE w.username = f.username AND w.bid = f.id))
|
|
1588
|
+
WHEN 'brewery' THEN COALESCE(
|
|
1589
|
+
(SELECT brewery_name FROM brewery_beer_lists l WHERE l.brewery_id = f.id),
|
|
1590
|
+
(SELECT brewery_name FROM distinct_beers d WHERE d.username = f.username AND d.brewery_id = f.id LIMIT 1),
|
|
1591
|
+
(SELECT brewery FROM beer_meta m WHERE m.brewery_id = f.id LIMIT 1),
|
|
1592
|
+
(SELECT brewery_name FROM checkins c WHERE c.username = f.username AND c.brewery_id = f.id LIMIT 1))
|
|
1593
|
+
ELSE (SELECT name FROM venues v WHERE v.venue_id = f.id) END`;
|
|
1594
|
+
const where = ['f.username = ?'];
|
|
1595
|
+
const params = [key];
|
|
1596
|
+
if (opts.kind) {
|
|
1597
|
+
where.push('f.kind = ?');
|
|
1598
|
+
params.push(opts.kind);
|
|
1599
|
+
}
|
|
1600
|
+
const run = (extra, extraParams) => this.db
|
|
1601
|
+
.all(`SELECT f.kind AS kind, f.id AS id, f.note AS note, f.created_at AS created_at, ${nameSql} AS name
|
|
1602
|
+
FROM favorites f WHERE ${[...where, ...(extra ? [extra] : [])].join(' AND ')}`, [...params, ...extraParams])
|
|
1603
|
+
.map((r) => ({
|
|
1604
|
+
kind: String(r.kind),
|
|
1605
|
+
id: Number(r.id),
|
|
1606
|
+
note: str(r.note),
|
|
1607
|
+
created_at: String(r.created_at),
|
|
1608
|
+
name: str(r.name),
|
|
1609
|
+
}));
|
|
1610
|
+
let rows;
|
|
1611
|
+
if (opts.ids === undefined)
|
|
1612
|
+
rows = run('', []);
|
|
1613
|
+
else {
|
|
1614
|
+
rows = [];
|
|
1615
|
+
for (const c of chunks([...new Set(opts.ids)])) {
|
|
1616
|
+
if (c.length)
|
|
1617
|
+
rows.push(...run(`f.id IN (${c.map(() => '?').join(', ')})`, c));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return rows.sort((a, b) => b.created_at.localeCompare(a.created_at) || a.kind.localeCompare(b.kind) || a.id - b.id);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* One row per distinct beer the user has had, from BOTH sources: user/beers
|
|
1624
|
+
* (rating = the user's rating, times = had_count) and check-ins for beers
|
|
1625
|
+
* user/beers doesn't hold. `best` is the max rating across both (as
|
|
1626
|
+
* hasHadMany), `avg_basis` the rating used for brewery averages.
|
|
1627
|
+
*/
|
|
1628
|
+
perBeerSql(bidFilter) {
|
|
1629
|
+
return `WITH c AS (
|
|
1630
|
+
SELECT bid, COUNT(*) AS n, MAX(NULLIF(rating, 0)) AS best, AVG(NULLIF(rating, 0)) AS avg_r,
|
|
1631
|
+
MAX(beer_name) AS beer_name, MAX(brewery_id) AS brewery_id, MAX(brewery_name) AS brewery_name
|
|
1632
|
+
FROM checkins WHERE username = ? AND bid IS NOT NULL${bidFilter} GROUP BY bid),
|
|
1633
|
+
d AS (
|
|
1634
|
+
SELECT bid, had_count, NULLIF(rating, 0) AS rating, beer_name, brewery_id, brewery_name
|
|
1635
|
+
FROM distinct_beers WHERE username = ?${bidFilter}),
|
|
1636
|
+
ids AS (SELECT bid FROM c UNION SELECT bid FROM d)
|
|
1637
|
+
SELECT ids.bid AS bid,
|
|
1638
|
+
COALESCE(d.beer_name, c.beer_name) AS beer_name,
|
|
1639
|
+
COALESCE(d.brewery_id, c.brewery_id) AS brewery_id,
|
|
1640
|
+
COALESCE(d.brewery_name, c.brewery_name) AS brewery_name,
|
|
1641
|
+
CASE WHEN d.rating IS NULL THEN c.best WHEN c.best IS NULL THEN d.rating
|
|
1642
|
+
WHEN d.rating > c.best THEN d.rating ELSE c.best END AS best,
|
|
1643
|
+
COALESCE(d.rating, c.avg_r) AS avg_basis,
|
|
1644
|
+
COALESCE(d.had_count, c.n, 1) AS times_had
|
|
1645
|
+
FROM ids LEFT JOIN d ON d.bid = ids.bid LEFT JOIN c ON c.bid = ids.bid`;
|
|
1646
|
+
}
|
|
1647
|
+
breweryHadStats(username, breweryIds) {
|
|
1648
|
+
const key = username.toLowerCase();
|
|
1649
|
+
const sql = (filter) => `SELECT brewery_id, MAX(brewery_name) AS brewery_name, COUNT(*) AS beers, COUNT(avg_basis) AS rated,
|
|
1650
|
+
AVG(avg_basis) AS avg_rating
|
|
1651
|
+
FROM (${this.perBeerSql('')}) WHERE brewery_id IS NOT NULL${filter} GROUP BY brewery_id`;
|
|
1652
|
+
const map = (r) => ({
|
|
1653
|
+
brewery_id: Number(r.brewery_id),
|
|
1654
|
+
brewery_name: str(r.brewery_name),
|
|
1655
|
+
beers: Number(r.beers),
|
|
1656
|
+
rated_beers: Number(r.rated),
|
|
1657
|
+
avg_rating: num(r.avg_rating),
|
|
1658
|
+
});
|
|
1659
|
+
if (breweryIds === undefined)
|
|
1660
|
+
return this.db.all(sql(''), [key, key]).map(map);
|
|
1661
|
+
const out = [];
|
|
1662
|
+
for (const c of chunks([...new Set(breweryIds)])) {
|
|
1663
|
+
if (c.length)
|
|
1664
|
+
out.push(...this.db.all(sql(` AND brewery_id IN (${c.map(() => '?').join(', ')})`), [key, key, ...c]).map(map));
|
|
1665
|
+
}
|
|
1666
|
+
return out;
|
|
1667
|
+
}
|
|
1668
|
+
beerHadStats(username, opts) {
|
|
1669
|
+
const key = username.toLowerCase();
|
|
1670
|
+
const map = (r) => ({
|
|
1671
|
+
bid: Number(r.bid),
|
|
1672
|
+
beer_name: str(r.beer_name),
|
|
1673
|
+
brewery_id: num(r.brewery_id),
|
|
1674
|
+
brewery_name: str(r.brewery_name),
|
|
1675
|
+
rating: num(r.best),
|
|
1676
|
+
times_had: Number(r.times_had),
|
|
1677
|
+
});
|
|
1678
|
+
if (opts.bids !== undefined) {
|
|
1679
|
+
const out = [];
|
|
1680
|
+
for (const c of chunks([...new Set(opts.bids)])) {
|
|
1681
|
+
if (c.length === 0)
|
|
1682
|
+
continue;
|
|
1683
|
+
const f = ` AND bid IN (${c.map(() => '?').join(', ')})`;
|
|
1684
|
+
out.push(...this.db.all(this.perBeerSql(f), [key, ...c, key, ...c]).map(map));
|
|
1685
|
+
}
|
|
1686
|
+
return out;
|
|
1687
|
+
}
|
|
1688
|
+
return this.db
|
|
1689
|
+
.all(`SELECT * FROM (${this.perBeerSql('')}) WHERE best >= ? OR times_had >= ?`, [
|
|
1690
|
+
key,
|
|
1691
|
+
key,
|
|
1692
|
+
opts.minRating ?? Number.MAX_VALUE,
|
|
1693
|
+
opts.minTimesHad ?? Number.MAX_SAFE_INTEGER,
|
|
1694
|
+
])
|
|
1695
|
+
.map(map);
|
|
1696
|
+
}
|
|
1697
|
+
venueVisitStats(username, opts) {
|
|
1698
|
+
const key = username.toLowerCase();
|
|
1699
|
+
const base = `SELECT u.*, v.name, v.category, v.address, v.city, v.state, v.country, v.lat, v.lng, v.is_verified
|
|
1700
|
+
FROM user_venues u LEFT JOIN venues v ON v.venue_id = u.venue_id
|
|
1701
|
+
WHERE u.username = ? AND COALESCE(u.total_count, 0) >= ?`;
|
|
1702
|
+
const min = opts.minCheckins ?? 0;
|
|
1703
|
+
if (opts.venueIds === undefined)
|
|
1704
|
+
return this.db.all(base, [key, min]).map(visitedFrom);
|
|
1705
|
+
const out = [];
|
|
1706
|
+
for (const c of chunks([...new Set(opts.venueIds)])) {
|
|
1707
|
+
if (c.length)
|
|
1708
|
+
out.push(...this.db.all(`${base} AND u.venue_id IN (${c.map(() => '?').join(', ')})`, [key, min, ...c]).map(visitedFrom));
|
|
1709
|
+
}
|
|
1710
|
+
return out;
|
|
1711
|
+
}
|
|
485
1712
|
getBeerMeta(bids) {
|
|
486
1713
|
if (bids.length === 0)
|
|
487
1714
|
return [];
|
|
@@ -547,6 +1774,9 @@ export class LocalCacheStore {
|
|
|
547
1774
|
async hasHad(username, opts) {
|
|
548
1775
|
return this.core.hasHad(username, opts);
|
|
549
1776
|
}
|
|
1777
|
+
async hasHadMany(username, bids) {
|
|
1778
|
+
return this.core.hasHadMany(username, bids);
|
|
1779
|
+
}
|
|
550
1780
|
async query(username, filters) {
|
|
551
1781
|
return this.core.query(username, filters);
|
|
552
1782
|
}
|
|
@@ -559,6 +1789,120 @@ export class LocalCacheStore {
|
|
|
559
1789
|
async forgetUser(username) {
|
|
560
1790
|
return this.core.forgetUser(username);
|
|
561
1791
|
}
|
|
1792
|
+
async getDistinctBeers(username, bids) {
|
|
1793
|
+
return this.core.getDistinctBeers(username, bids);
|
|
1794
|
+
}
|
|
1795
|
+
async getResolutions(keys) {
|
|
1796
|
+
return this.core.getResolutions(keys);
|
|
1797
|
+
}
|
|
1798
|
+
async upsertResolutions(rows) {
|
|
1799
|
+
this.core.upsertResolutions(rows);
|
|
1800
|
+
}
|
|
1801
|
+
async stats() {
|
|
1802
|
+
return this.core.stats();
|
|
1803
|
+
}
|
|
1804
|
+
async upsertUserVenues(username, items) {
|
|
1805
|
+
return this.core.upsertUserVenues(username, items);
|
|
1806
|
+
}
|
|
1807
|
+
async getUserVenueVisits(username, venueIds) {
|
|
1808
|
+
return this.core.getUserVenueVisits(username, venueIds);
|
|
1809
|
+
}
|
|
1810
|
+
async userVenuesCount(username) {
|
|
1811
|
+
return this.core.userVenuesCount(username);
|
|
1812
|
+
}
|
|
1813
|
+
async getVenues(venueIds) {
|
|
1814
|
+
return this.core.getVenues(venueIds);
|
|
1815
|
+
}
|
|
1816
|
+
async getListState(username, list) {
|
|
1817
|
+
return this.core.getListState(username, list);
|
|
1818
|
+
}
|
|
1819
|
+
async setListState(username, list, patch) {
|
|
1820
|
+
this.core.setListState(username, list, patch);
|
|
1821
|
+
}
|
|
1822
|
+
async findVisitedVenues(username, q) {
|
|
1823
|
+
return this.core.findVisitedVenues(username, q);
|
|
1824
|
+
}
|
|
1825
|
+
async getBreweryBeerList(breweryId) {
|
|
1826
|
+
return this.core.getBreweryBeerList(breweryId);
|
|
1827
|
+
}
|
|
1828
|
+
async saveBreweryBeerPage(page) {
|
|
1829
|
+
this.core.saveBreweryBeerPage(page);
|
|
1830
|
+
}
|
|
1831
|
+
async getCachedResponse(key) {
|
|
1832
|
+
return this.core.getCachedResponse(key);
|
|
1833
|
+
}
|
|
1834
|
+
async putCachedResponse(key, kind, data, fetchedAt) {
|
|
1835
|
+
this.core.putCachedResponse(key, kind, data, fetchedAt);
|
|
1836
|
+
}
|
|
1837
|
+
async upsertWishlist(username, rows, passStamp) {
|
|
1838
|
+
return this.core.upsertWishlist(username, rows, passStamp);
|
|
1839
|
+
}
|
|
1840
|
+
async pruneWishlist(username, passStamp) {
|
|
1841
|
+
return this.core.pruneWishlist(username, passStamp);
|
|
1842
|
+
}
|
|
1843
|
+
async wishlistCount(username) {
|
|
1844
|
+
return this.core.wishlistCount(username);
|
|
1845
|
+
}
|
|
1846
|
+
async getWishlist(username, bids) {
|
|
1847
|
+
return this.core.getWishlist(username, bids);
|
|
1848
|
+
}
|
|
1849
|
+
async upsertBreweryLikes(username, rows, passStamp) {
|
|
1850
|
+
return this.core.upsertBreweryLikes(username, rows, passStamp);
|
|
1851
|
+
}
|
|
1852
|
+
async pruneBreweryLikes(username, passStamp) {
|
|
1853
|
+
return this.core.pruneBreweryLikes(username, passStamp);
|
|
1854
|
+
}
|
|
1855
|
+
async breweryLikesCount(username) {
|
|
1856
|
+
return this.core.breweryLikesCount(username);
|
|
1857
|
+
}
|
|
1858
|
+
async getBreweryLikes(username, breweryIds) {
|
|
1859
|
+
return this.core.getBreweryLikes(username, breweryIds);
|
|
1860
|
+
}
|
|
1861
|
+
async breweryLikesCoverage(username) {
|
|
1862
|
+
return this.core.breweryLikesCoverage(username);
|
|
1863
|
+
}
|
|
1864
|
+
async venueDrinkCandidates(username, opts) {
|
|
1865
|
+
return this.core.venueDrinkCandidates(username, opts);
|
|
1866
|
+
}
|
|
1867
|
+
async upsertVenueDrinks(username, rows, fetchedAt) {
|
|
1868
|
+
this.core.upsertVenueDrinks(username, rows, fetchedAt);
|
|
1869
|
+
}
|
|
1870
|
+
async getVenueDrinks(username, venueIds) {
|
|
1871
|
+
return this.core.getVenueDrinks(username, venueIds);
|
|
1872
|
+
}
|
|
1873
|
+
async addFavorite(username, kind, id, note, createdAt) {
|
|
1874
|
+
return this.core.addFavorite(username, kind, id, note, createdAt);
|
|
1875
|
+
}
|
|
1876
|
+
async removeFavorite(username, kind, id) {
|
|
1877
|
+
return this.core.removeFavorite(username, kind, id);
|
|
1878
|
+
}
|
|
1879
|
+
async getManualFavorites(username, opts) {
|
|
1880
|
+
return this.core.getManualFavorites(username, opts);
|
|
1881
|
+
}
|
|
1882
|
+
async breweryHadStats(username, breweryIds) {
|
|
1883
|
+
return this.core.breweryHadStats(username, breweryIds);
|
|
1884
|
+
}
|
|
1885
|
+
async beerHadStats(username, opts) {
|
|
1886
|
+
return this.core.beerHadStats(username, opts);
|
|
1887
|
+
}
|
|
1888
|
+
async venueVisitStats(username, opts) {
|
|
1889
|
+
return this.core.venueVisitStats(username, opts);
|
|
1890
|
+
}
|
|
1891
|
+
async recordApiCalls(rows) {
|
|
1892
|
+
this.core.recordApiCalls(rows);
|
|
1893
|
+
}
|
|
1894
|
+
async recentApiCalls(sinceMs) {
|
|
1895
|
+
return this.core.recentApiCalls(sinceMs);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
// SQLite caps bound parameters per statement (999 on older builds); IN-lists
|
|
1899
|
+
// are split well under that.
|
|
1900
|
+
const IN_CHUNK = 500;
|
|
1901
|
+
function chunks(xs) {
|
|
1902
|
+
const out = [];
|
|
1903
|
+
for (let i = 0; i < xs.length; i += IN_CHUNK)
|
|
1904
|
+
out.push(xs.slice(i, i + IN_CHUNK));
|
|
1905
|
+
return out;
|
|
562
1906
|
}
|
|
563
1907
|
// Case-insensitive substring match whose pattern is built by escapeLike(): the
|
|
564
1908
|
// ESCAPE clause makes a backslash-prefixed %, _ or backslash match itself literally.
|
|
@@ -568,3 +1912,59 @@ const LIKE_MATCH = "LIKE ? COLLATE NOCASE ESCAPE '\\'";
|
|
|
568
1912
|
function escapeLike(s) {
|
|
569
1913
|
return s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
570
1914
|
}
|
|
1915
|
+
function visitFrom(r) {
|
|
1916
|
+
return {
|
|
1917
|
+
venue_id: Number(r.venue_id),
|
|
1918
|
+
first_visit: str(r.first_visit),
|
|
1919
|
+
first_checkin_id: num(r.first_checkin_id),
|
|
1920
|
+
recent_visit: str(r.recent_visit),
|
|
1921
|
+
recent_checkin_id: num(r.recent_checkin_id),
|
|
1922
|
+
total_count: num(r.total_count),
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
function visitedFrom(r) {
|
|
1926
|
+
const name = str(r.name);
|
|
1927
|
+
return {
|
|
1928
|
+
name,
|
|
1929
|
+
category: str(r.category),
|
|
1930
|
+
address: str(r.address),
|
|
1931
|
+
city: str(r.city),
|
|
1932
|
+
state: str(r.state),
|
|
1933
|
+
country: str(r.country),
|
|
1934
|
+
lat: num(r.lat),
|
|
1935
|
+
lng: num(r.lng),
|
|
1936
|
+
is_verified: num(r.is_verified),
|
|
1937
|
+
...visitFrom(r),
|
|
1938
|
+
is_duplicate: name !== null && /\(duplicate of\b/i.test(name),
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
// Words that don't distinguish one venue name from another ("Green Bench
|
|
1942
|
+
// Brewing Co." vs "Green Bench Brewing Company").
|
|
1943
|
+
const NAME_STOP_WORDS = new Set(['co', 'company', 'inc', 'llc', 'ltd', 'the', 'and']);
|
|
1944
|
+
/** Lowercased alphanumeric words of a name, minus {@link NAME_STOP_WORDS}. */
|
|
1945
|
+
function significantTokens(name) {
|
|
1946
|
+
return wordsOf(name).filter((w) => !NAME_STOP_WORDS.has(w));
|
|
1947
|
+
}
|
|
1948
|
+
function wordsOf(s) {
|
|
1949
|
+
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
|
|
1950
|
+
}
|
|
1951
|
+
/** See {@link CheckinStoreCore.findVisitedVenues}: literal substring, or every significant word whole. */
|
|
1952
|
+
function nameMatches(query, tokens, venueName) {
|
|
1953
|
+
if (venueName === null)
|
|
1954
|
+
return false;
|
|
1955
|
+
if (venueName.toLowerCase().includes(query.toLowerCase()))
|
|
1956
|
+
return true;
|
|
1957
|
+
if (tokens.length === 0)
|
|
1958
|
+
return false;
|
|
1959
|
+
const words = new Set(wordsOf(venueName));
|
|
1960
|
+
return tokens.every((t) => words.has(t));
|
|
1961
|
+
}
|
|
1962
|
+
/** Great-circle distance in metres. */
|
|
1963
|
+
export function haversineM(lat1, lng1, lat2, lng2) {
|
|
1964
|
+
const R = 6_371_008.8;
|
|
1965
|
+
const rad = Math.PI / 180;
|
|
1966
|
+
const dLat = (lat2 - lat1) * rad;
|
|
1967
|
+
const dLng = (lng2 - lng1) * rad;
|
|
1968
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) ** 2;
|
|
1969
|
+
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
1970
|
+
}
|