feat: DB migration — pgvector, Postgres caches, leaderboard, security#199
Conversation
Phase 1 migration (0002): definitions, word_stats, wiktionary_cache, semantic_hints — replacing disk-based JSON files with atomic DB operations. Phase 2 migration (0003): pgvector extension + word_embeddings table with HNSW index, semantic_axes, and target_neighbors for precomputed rank lookups. Replaces the 98-230MB in-memory embedding matrix. New db-cache.ts utility: DRY module for all cache operations with DB-first + disk fallback pattern.
- semantic-db.ts: pgvector-backed rank lookup, kNN, embedding fetch - definitions.ts: DB-first with disk fallback (4-tier cache) - word-stats.ts: atomic DB increment (no lockfile), disk fallback - semantic-warmup.ts: conditional loading (SEMANTIC_DB=1 skips 98MB) - prisma.ts: pool max 10→20 for pgvector query concurrency - migrate-caches-to-db.ts: import disk cache files into Postgres - seed-semantic-db.ts: import embeddings + precompute target_neighbors
- wiktionary.ts: DB-first with disk fallback - semantic/hint.post.ts: DB cache for LLM hints - semantic/guess.post.ts: SEMANTIC_DB=1 flag enables pgvector rank lookup via target_neighbors table, with in-memory fallback - semantic/reveal.post.ts: pgvector kNN for neighbor reveal - seed-semantic-db.ts: sanitize NaN/Infinity float coordinates
- Add per-mode slot offsets to getWordsForDay() so daily words differ between classic, dordle, quordle, etc. (previously each mode was a strict subset of the next larger mode) - Track used words in speed streak with a Set to prevent repeats within a session (previously only prevented consecutive repeats) - Parallelize independent DB queries in semantic guess endpoint - Cache getTotalRanked() result in memory (changes only at seed time) - Add await to loadWordStats caller (sync→async fix)
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReplaces in-memory/disk caches with Postgres-backed caches and pgvector semantic storage; adds Prisma schema/migrations, DB utilities, migration/seed/benchmark scripts, inflight deduping, DB-first server endpoints with disk fallbacks, UI scrollbar utility, and multiple frontend/layout adjustments. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant API as Server API
participant SD as SemanticDB
participant DB as Postgres
participant Disk as Disk/In-Memory
Client->>API: POST /[lang]/semantic/guess {guess,target,...}
API->>SD: request embeddings, rank, neighbors, axes (parallel)
SD->>DB: SELECT embedding / semantic_axes / target_neighbors / knn
DB-->>SD: vectors, axes, neighbors, counts
SD-->>API: rank, similarity, 2D position, normalized axis scores
API-->>Client: response (rank, similarity, projections)
alt DB miss or missing embedding
API->>Disk: fallback to in-memory/LLM (embed on-demand)
Disk-->>API: computed embedding/hint
API-->>Client: fallback response
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
- Remove dead .sidebar-scroll CSS from AppSidebar (replaced by global editorial-scroll) - Remove :deep(.canvas-wrap) !important hack from semantic page — parent max-height + component's own mobile responsive rules handle this - Extract magic 310px into --map-chrome CSS custom property
There was a problem hiding this comment.
Actionable comments posted: 15
🧹 Nitpick comments (2)
assets/css/design-system.css (1)
233-250: Consolidate remaining duplicate scrollbar utilities into this global class.This new
.editorial-scrollis a good source of truth, butcomponents/app/AppSidebar.vuestill has a duplicated.sidebar-scrollblock (Lines 546-565). Consider switching that component toeditorial-scrolland removing the duplicate scoped CSS to prevent style drift.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@assets/css/design-system.css` around lines 233 - 250, Replace the duplicated scrollbar CSS in the AppSidebar component by removing the scoped .sidebar-scroll block and updating the template to use the global .editorial-scroll class instead: locate the .sidebar-scroll CSS in components/app/AppSidebar.vue, delete that duplicated CSS, and change any element/class references from "sidebar-scroll" to "editorial-scroll" so the component uses the global editorial-scroll utility.server/utils/word-selection.ts (1)
337-337: Avoid silent fallback to classic offset for unmapped modes.At Line 337,
?? 0hides missing mode mappings and can silently reintroduce collisions. Prefer failing fast for unknown modes.💡 Suggested guard
- const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0; + const modeOffset = MODE_SLOT_OFFSETS[mode]; + if (modeOffset === undefined) { + throw new Error(`[word-selection] Missing MODE_SLOT_OFFSETS entry for mode "${mode}"`); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/word-selection.ts` at line 337, The code silently falls back to 0 by using MODE_SLOT_OFFSETS[mode] ?? 0 which can hide missing mappings; change this to explicitly guard for unknown modes by checking that MODE_SLOT_OFFSETS has the key for the given mode (e.g., using Object.prototype.hasOwnProperty.call(MODE_SLOT_OFFSETS, mode) or mode in MODE_SLOT_OFFSETS) and if missing throw an informative Error (including the mode value) instead of defaulting to 0 so callers fail fast; update the assignment of modeOffset (currently using MODE_SLOT_OFFSETS[mode] ?? 0) to use the guarded lookup and thrown error when unmapped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@components/shared/BaseModal.vue`:
- Line 30: Add an optional prop to BaseModal.vue (e.g., scrollable: boolean =
true or disableScroll: boolean = false) and use it to conditionally apply or
remove the outer modal scroll classes (the class string containing
"max-h-[calc(100dvh-2rem)] overflow-y-auto" on the modal container) so consumers
like LanguagePickerModal can opt out of the outer scrolling; update the
component props and the template to toggle those classes based on the new prop
and document the prop usage for consumers.
In `@pages/`[lang]/semantic.vue:
- Around line 727-728: The `@media` (max-width: 380px) rule currently sets a
larger min-height than the `@media` (max-width: 520px) rule, which reduces usable
space on smallest screens; update the smaller-screen media query to use a
no-larger min-height (e.g., change its min-height to min(180px, 40dvh) or a
smaller value) so it is not greater than the min-height used at max-width:520px,
and ensure max-height remains 40dvh; modify the CSS rule that sets min-height
and max-height (look for the `@media` (max-width: 380px) block and the min-height
/ max-height properties) accordingly.
- Around line 649-654: The inline width on the MeaningMap element prevents the
square from scaling on short viewports; update the CSS selector .map-canvas-wrap
:deep(.canvas-wrap) to also override the inline width (e.g., set width: auto
!important or width: 100% !important) so width and height scale together and
respect the aspect-ratio/max-height rules used for mobile. Target the same
selector used for height and ensure the override uses !important to beat the
inline style coming from the MeaningMap component.
In `@scripts/migrate-caches-to-db.ts`:
- Around line 34-35: The script hard-codes WORD_DEFS_DIR and WORD_STATS_DIR to
process.cwd(), causing migrations to miss caches when the app uses a different
persistent root; change the script to resolve these directories from the same
PERSISTENT_DIR used by server/utils/data-loader.ts (i.e., import or require the
PERSISTENT_DIR symbol and set WORD_DEFS_DIR = join(PERSISTENT_DIR, 'word-defs')
and WORD_STATS_DIR = join(PERSISTENT_DIR, 'word-stats')), optionally keeping
process.cwd() as a fallback if PERSISTENT_DIR is undefined.
In `@scripts/seed-semantic-db.ts`:
- Around line 225-283: The code currently ranks every embedding in
words/embeddings (using variables words, embeddings, wordIndex, cosines,
indices, TOP_K_NEIGHBORS) but must only seed target_neighbors from vocab
entries; change the loop to first build a filtered list of vocab-only indices
(e.g. vocabIndices) based on your is_vocab marker for each word, compute cosines
only against that vocabIndices set (or compute for all but then map to
vocabIndices for ranking), sort that filtered index list by cosine, set k =
Math.min(TOP_K_NEIGHBORS, vocabIndices.length), and insert neighbors using
words[vocabIdx] and cosines[vocabIdx] so DB rows and
getTotalRanked()/computeGuessRank() see the same vocab-only leaderboard.
In `@server/api/`[lang]/semantic/guess.post.ts:
- Around line 89-102: The code stores raw dot products into
normalizedGuessProjections using
semanticDb.getCachedAxesVectors()/getCachedAxesNames(), but it must normalize
each axis like the legacy path: for each axis (loop over axesNames), compute the
axis vector norm (sqrt of sum of squares of axesVectors for that axis using
dims), guard against zero norm, then divide the dot product (computed from
guessVec and axesVectors) by that norm before assigning into
normalizedGuessProjections; implement either a precomputed axisNorms array or
compute the norm inline in the same loop so normalizedGuessProjections and the
legacy branch use the same scale.
In `@server/api/`[lang]/semantic/hint.post.ts:
- Around line 145-158: The disk fallback cache is only keyed by target, causing
cross-language collisions; update the cache key used when creating cacheFile so
it includes lang (e.g., incorporate lang into the filename or a language
subdirectory) so the disk tier matches the DB tier's (lang, target) key; adjust
any related uses of CACHE_DIR/cacheFile (in this file's disk read/write logic)
to use the new lang-scoped path and ensure directories are created with
mkdirSync as needed.
In `@server/api/`[lang]/semantic/reveal.post.ts:
- Around line 43-71: Wrap the DB-backed branch (guarded by USE_DB) in a
try/catch and validate results from semanticDb calls (e.g.,
semanticDb.get2dPosition, semanticDb.knnNearest, semanticDb.getTotalRanked,
semanticDb.computeGuessRank); if any call throws or returns
null/undefined/incomplete data (empty neighbours, missing totalRanked, or no
targetUmap), log the error and fall back to the existing legacy in-memory path
(the code below this branch) instead of returning a degraded payload—ensure the
catch returns control to the legacy flow rather than returning a DB result.
- Around line 50-62: The code sets allProjectionsNormalized to an empty object
which breaks the legacy contract; when enriching neighbours inside the
Promise.all (the mapping over neighbours where you call
semanticDb.computeGuessRank and use rankToDisplay), populate
allProjectionsNormalized from the neighbour record (use
n.allProjectionsNormalized if present) or compute it the same way the legacy
path does (e.g., call the existing normalization helper or derive from
n.projections and the same projection ranges used elsewhere) so the response
includes the normalized axis projections for each neighbour instead of {}.
In `@server/utils/definitions.ts`:
- Around line 276-279: The dynamic import of './db-cache' should be guarded so a
failing DB module doesn't abort fetchDefinition and prevent disk/LLM/Kaikki
fallbacks; wrap the await import('./db-cache') in a try/catch, assign safe
defaults (e.g., getDefinition = async ()=>null and upsertDefinition = async
()=>{}) when the import fails, and only call getDefinition/upsertDefinition if
they are the real functions; update the code locations referencing getDefinition
and upsertDefinition to handle null/placeholder responses so the remaining tiers
still execute.
In `@server/utils/semantic-db.ts`:
- Around line 114-116: In get2dPosition (server/utils/semantic-db.ts) replace
the invalid prisma.$raw(...) fragments with Prisma.raw(...) by importing Prisma
from '@prisma/client' at the top; keep using prisma.$queryRaw<Array<{ x: number;
y: number }>> for execution but interpolate dynamic column fragments via
Prisma.raw(col1) and Prisma.raw(col2) (and any other dynamic SQL fragments), and
update the import section to include "Prisma" so the code uses the correct
static helper instead of the non-existent prisma.$raw method.
- Around line 34-85: The cache currently holds a single {lang, axes,
axesVectors} and races across languages; change it to a per-language cache
(e.g., Map<string, { axes: AxisData[]; axesVectors: Float32Array }>) in place of
_axesCache, store entries keyed by lang inside loadAxes, and update the getters
getCachedAxesVectors and getCachedAxesNames to accept a lang parameter and
return the cached entry for that lang (or null/empty). Also search for and
update any call sites that call getCachedAxesVectors() / getCachedAxesNames() to
pass the current language so callers get the correct per-language data.
In `@server/utils/word-stats.ts`:
- Around line 50-57: updateWordStats() never reaches the disk fallback because
incrementWordStats() in db-cache.ts swallows Prisma errors and returns void;
change the contract so failures propagate: modify incrementWordStats (in
server/utils/db-cache.ts) to either rethrow caught errors or return a failure
boolean, and update updateWordStats (in server/utils/word-stats.ts) to detect
the failure (catch the rethrown error or inspect the boolean) and only return
early on success; if failure, fall through to call _writeStats() so the disk
fallback runs.
- Around line 20-25: The exported function loadWordStats is now async but some
GET handlers still call it synchronously; update each caller to await
loadWordStats and propagate async: mark the handler functions as async, replace
direct uses of loadWordStats(...) with await loadWordStats(...), and when
loading stats for multiple words use Promise.all (e.g., await
Promise.all(words.map(w => loadWordStats(...)))) to resolve all Promises before
building the response payload so the handlers return real WordStats instead of
Promises.
In `@stores/game.ts`:
- Around line 1913-1923: The selection loop can hang if `list` contains
duplicates because `_speedUsedWords.size < list.length` uses the raw list
length; compute the set of unique words (e.g., const unique = Array.from(new
Set(list)) or const uniqueCount = new Set(list).size) and use that unique
array/count for the size comparison and for random sampling; update the clear
check, the while condition, and the random pick to reference the deduplicated
collection (`unique` / `uniqueCount`) while still adding the chosen `word` to
`_speedUsedWords`.
---
Nitpick comments:
In `@assets/css/design-system.css`:
- Around line 233-250: Replace the duplicated scrollbar CSS in the AppSidebar
component by removing the scoped .sidebar-scroll block and updating the template
to use the global .editorial-scroll class instead: locate the .sidebar-scroll
CSS in components/app/AppSidebar.vue, delete that duplicated CSS, and change any
element/class references from "sidebar-scroll" to "editorial-scroll" so the
component uses the global editorial-scroll utility.
In `@server/utils/word-selection.ts`:
- Line 337: The code silently falls back to 0 by using MODE_SLOT_OFFSETS[mode]
?? 0 which can hide missing mappings; change this to explicitly guard for
unknown modes by checking that MODE_SLOT_OFFSETS has the key for the given mode
(e.g., using Object.prototype.hasOwnProperty.call(MODE_SLOT_OFFSETS, mode) or
mode in MODE_SLOT_OFFSETS) and if missing throw an informative Error (including
the mode value) instead of defaulting to 0 so callers fail fast; update the
assignment of modeOffset (currently using MODE_SLOT_OFFSETS[mode] ?? 0) to use
the guarded lookup and thrown error when unmapped.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 190b3d00-6087-44f5-b6b7-5e2b8fc86cc8
📒 Files selected for processing (25)
assets/css/design-system.csscomponents/app/AppHeader.vuecomponents/semantic/SemanticLeaderboard.vuecomponents/shared/BaseModal.vuepages/[lang]/semantic.vueprisma/migrations/0002_cache_tables/migration.sqlprisma/migrations/0003_semantic_embeddings/migration.sqlprisma/schema.prismascripts/migrate-caches-to-db.tsscripts/seed-semantic-db.tsserver/api/[lang]/data.get.tsserver/api/[lang]/semantic/guess.post.tsserver/api/[lang]/semantic/hint.post.tsserver/api/[lang]/semantic/reveal.post.tsserver/api/[lang]/word-stats.post.tsserver/api/[lang]/words.get.tsserver/plugins/semantic-warmup.tsserver/utils/db-cache.tsserver/utils/definitions.tsserver/utils/prisma.tsserver/utils/semantic-db.tsserver/utils/wiktionary.tsserver/utils/word-selection.tsserver/utils/word-stats.tsstores/game.ts
| :aria-label="ariaLabel || undefined" | ||
| tabindex="-1" | ||
| class="relative w-full border border-rule bg-paper text-ink shadow-xl z-10 modal-animate" | ||
| class="relative w-full border border-rule bg-paper text-ink shadow-xl z-10 modal-animate max-h-[calc(100dvh-2rem)] overflow-y-auto editorial-scroll" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "BaseModal.vue" -o -name "LanguagePickerModal.vue" | head -20Repository: Hugo0/wordle
Length of output: 130
🏁 Script executed:
wc -l ./components/shared/BaseModal.vue ./components/app/LanguagePickerModal.vueRepository: Hugo0/wordle
Length of output: 151
🏁 Script executed:
cat -n ./components/shared/BaseModal.vue | head -50Repository: Hugo0/wordle
Length of output: 2607
🏁 Script executed:
cat -n ./components/app/LanguagePickerModal.vue | head -50Repository: Hugo0/wordle
Length of output: 2505
🏁 Script executed:
rg "BaseModal" --type vue -A 5 | grep -E "(BaseModal|overflow|max-h)" | head -40Repository: Hugo0/wordle
Length of output: 83
🏁 Script executed:
rg "BaseModal" --glob "*.vue" -B 2 -A 8 | head -80Repository: Hugo0/wordle
Length of output: 4869
🏁 Script executed:
cat -n ./components/shared/BaseModal.vue | sed -n '15,35p'Repository: Hugo0/wordle
Length of output: 1298
Line 30: Nested modal scrolling is intentional but review constraints.
The modal dialog has overflow-y-auto alongside an inner max-h-[60vh] scrollable area (e.g., LanguagePickerModal, line 29). However, the modal's max-h-[calc(100dvh-2rem)] constraint and outer container's py-4 padding create a managed scroll hierarchy where the outer container only scrolls on very constrained viewports. The current design handles most cases without actual double-scroll issues. If consumers need full control over modal scrolling behavior, consider adding an optional disableScroll or scrollable prop to BaseModal, though this is not critical given existing constraints.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@components/shared/BaseModal.vue` at line 30, Add an optional prop to
BaseModal.vue (e.g., scrollable: boolean = true or disableScroll: boolean =
false) and use it to conditionally apply or remove the outer modal scroll
classes (the class string containing "max-h-[calc(100dvh-2rem)] overflow-y-auto"
on the modal container) so consumers like LanguagePickerModal can opt out of the
outer scrolling; update the component props and the template to toggle those
classes based on the new prop and document the prop usage for consumers.
| const { words, embeddings } = loadEmbeddings(); | ||
| const targetsRaw = loadJson<{ targets?: string[] } | string[]>('targets.json'); | ||
| const targets = Array.isArray(targetsRaw) ? targetsRaw : targetsRaw.targets ?? []; | ||
| const N = words.length; | ||
|
|
||
| // Build word → index map | ||
| const wordIndex = new Map<string, number>(); | ||
| for (let i = 0; i < N; i++) wordIndex.set(words[i]!, i); | ||
|
|
||
| console.log( | ||
| `[seed] Computing target neighbors: ${targets.length} targets × top ${TOP_K_NEIGHBORS}...` | ||
| ); | ||
| console.log(' (This takes ~30 minutes for 879 targets × 50k vocab)'); | ||
|
|
||
| const client = await pool.connect(); | ||
| try { | ||
| await client.query('DELETE FROM wordle.target_neighbors WHERE lang = $1', [LANG]); | ||
|
|
||
| for (let t = 0; t < targets.length; t++) { | ||
| const target = targets[t]!; | ||
| const targetIdx = wordIndex.get(target); | ||
| if (targetIdx === undefined) { | ||
| console.warn(` [skip] target "${target}" not in vocab`); | ||
| continue; | ||
| } | ||
|
|
||
| // Compute cosine to all vocab words | ||
| const cosines = new Float32Array(N); | ||
| for (let i = 0; i < N; i++) { | ||
| let dot = 0; | ||
| for (let j = 0; j < DIMS; j++) { | ||
| dot += | ||
| embeddings[targetIdx * DIMS + j]! * | ||
| embeddings[i * DIMS + j]!; | ||
| } | ||
| cosines[i] = dot; | ||
| } | ||
|
|
||
| // Get top K indices by cosine (descending) | ||
| const indices = Array.from({ length: N }, (_, i) => i); | ||
| indices.sort((a, b) => cosines[b]! - cosines[a]!); | ||
|
|
||
| // Batch insert top K neighbors | ||
| const k = Math.min(TOP_K_NEIGHBORS, N); | ||
| const batchValues: string[] = []; | ||
| const batchParams: any[] = []; | ||
| let pIdx = 1; | ||
|
|
||
| for (let rank = 0; rank < k; rank++) { | ||
| const idx = indices[rank]!; | ||
| batchValues.push( | ||
| `($${pIdx++}, $${pIdx++}, $${pIdx++}, $${pIdx++}, $${pIdx++})` | ||
| ); | ||
| batchParams.push( | ||
| LANG, | ||
| target, | ||
| words[idx]!, | ||
| rank + 1, | ||
| cosines[idx]! |
There was a problem hiding this comment.
Only seed target_neighbors from vocab entries.
This loop ranks against every embedding in words, but runtime getTotalRanked() counts only is_vocab = true rows and computeGuessRank() treats target_neighbors as the vocab leaderboard. If the embedding corpus contains non-vocab rows, DB mode will return ranks/display values that are inconsistent with the legacy cosine ranking.
Suggested fix
async function seedTargetNeighbors() {
const { words, embeddings } = loadEmbeddings();
const targetsRaw = loadJson<{ targets?: string[] } | string[]>('targets.json');
const targets = Array.isArray(targetsRaw) ? targetsRaw : targetsRaw.targets ?? [];
+ const vocabData = loadJson<{ words?: string[] } | string[]>('vocabulary.json');
+ const vocabulary = new Set<string>(Array.isArray(vocabData) ? vocabData : vocabData.words ?? []);
const N = words.length;
+ const vocabIndices = words.flatMap((word, i) => (vocabulary.has(word) ? [i] : []));
// Build word → index map
const wordIndex = new Map<string, number>();
for (let i = 0; i < N; i++) wordIndex.set(words[i]!, i);
@@
- const cosines = new Float32Array(N);
- for (let i = 0; i < N; i++) {
+ const cosines = new Float32Array(N);
+ for (const i of vocabIndices) {
let dot = 0;
for (let j = 0; j < DIMS; j++) {
dot +=
embeddings[targetIdx * DIMS + j]! *
embeddings[i * DIMS + j]!;
}
cosines[i] = dot;
}
// Get top K indices by cosine (descending)
- const indices = Array.from({ length: N }, (_, i) => i);
+ const indices = [...vocabIndices];
indices.sort((a, b) => cosines[b]! - cosines[a]!);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/seed-semantic-db.ts` around lines 225 - 283, The code currently ranks
every embedding in words/embeddings (using variables words, embeddings,
wordIndex, cosines, indices, TOP_K_NEIGHBORS) but must only seed
target_neighbors from vocab entries; change the loop to first build a filtered
list of vocab-only indices (e.g. vocabIndices) based on your is_vocab marker for
each word, compute cosines only against that vocabIndices set (or compute for
all but then map to vocabIndices for ranking), sort that filtered index list by
cosine, set k = Math.min(TOP_K_NEIGHBORS, vocabIndices.length), and insert
neighbors using words[vocabIdx] and cosines[vocabIdx] so DB rows and
getTotalRanked()/computeGuessRank() see the same vocab-only leaderboard.
| let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null; | ||
|
|
||
| /** | ||
| * Load axis data from DB. Called once at startup, cached forever. | ||
| * Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible. | ||
| */ | ||
| export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> { | ||
| if (_axesCache?.lang === lang) return _axesCache.axes; | ||
|
|
||
| const rows = await prisma.$queryRaw< | ||
| Array<{ | ||
| name: string; | ||
| low_anchor: string; | ||
| high_anchor: string; | ||
| vector: string; // pgvector returns as string "[0.1,0.2,...]" | ||
| auc: number | null; | ||
| range_p5: number | null; | ||
| range_p95: number | null; | ||
| }> | ||
| >`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95 | ||
| FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`; | ||
|
|
||
| const axes: AxisData[] = rows.map((r) => ({ | ||
| name: r.name, | ||
| lowAnchor: r.low_anchor, | ||
| highAnchor: r.high_anchor, | ||
| vector: parseVector(r.vector), | ||
| auc: r.auc ?? 0, | ||
| rangeP5: r.range_p5 ?? 0, | ||
| rangeP95: r.range_p95 ?? 0, | ||
| })); | ||
|
|
||
| // Build concatenated axes vector array for fast dot products | ||
| const dims = axes[0]?.vector.length ?? 512; | ||
| const axesVectors = new Float32Array(axes.length * dims); | ||
| for (let a = 0; a < axes.length; a++) { | ||
| axesVectors.set(axes[a]!.vector, a * dims); | ||
| } | ||
|
|
||
| _axesCache = { lang, axes, axesVectors }; | ||
| return axes; | ||
| } | ||
|
|
||
| /** Get cached axes vectors for dot product computation. */ | ||
| export function getCachedAxesVectors(): Float32Array | null { | ||
| return _axesCache?.axesVectors ?? null; | ||
| } | ||
|
|
||
| /** Get cached axis names in order. */ | ||
| export function getCachedAxesNames(): string[] { | ||
| return _axesCache?.axes.map((a) => a.name) ?? []; | ||
| } |
There was a problem hiding this comment.
Scope the axes cache by language.
_axesCache only remembers one { lang, axes } bundle, but callers serve arbitrary route languages and read it back through lang-less getters. After another language loads, whichever request warmed the cache last wins, so later responses can return the wrong axis names/vectors.
Suggested fix
-let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null;
+const _axesCache = new Map<string, { axes: AxisData[]; axesVectors: Float32Array }>();
export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> {
- if (_axesCache?.lang === lang) return _axesCache.axes;
+ const cached = _axesCache.get(lang);
+ if (cached) return cached.axes;
@@
- _axesCache = { lang, axes, axesVectors };
+ _axesCache.set(lang, { axes, axesVectors });
return axes;
}
-export function getCachedAxesVectors(): Float32Array | null {
- return _axesCache?.axesVectors ?? null;
+export function getCachedAxesVectors(lang: string): Float32Array | null {
+ return _axesCache.get(lang)?.axesVectors ?? null;
}
-export function getCachedAxesNames(): string[] {
- return _axesCache?.axes.map((a) => a.name) ?? [];
+export function getCachedAxesNames(lang: string): string[] {
+ return _axesCache.get(lang)?.axes.map((a) => a.name) ?? [];
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null; | |
| /** | |
| * Load axis data from DB. Called once at startup, cached forever. | |
| * Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible. | |
| */ | |
| export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> { | |
| if (_axesCache?.lang === lang) return _axesCache.axes; | |
| const rows = await prisma.$queryRaw< | |
| Array<{ | |
| name: string; | |
| low_anchor: string; | |
| high_anchor: string; | |
| vector: string; // pgvector returns as string "[0.1,0.2,...]" | |
| auc: number | null; | |
| range_p5: number | null; | |
| range_p95: number | null; | |
| }> | |
| >`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95 | |
| FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`; | |
| const axes: AxisData[] = rows.map((r) => ({ | |
| name: r.name, | |
| lowAnchor: r.low_anchor, | |
| highAnchor: r.high_anchor, | |
| vector: parseVector(r.vector), | |
| auc: r.auc ?? 0, | |
| rangeP5: r.range_p5 ?? 0, | |
| rangeP95: r.range_p95 ?? 0, | |
| })); | |
| // Build concatenated axes vector array for fast dot products | |
| const dims = axes[0]?.vector.length ?? 512; | |
| const axesVectors = new Float32Array(axes.length * dims); | |
| for (let a = 0; a < axes.length; a++) { | |
| axesVectors.set(axes[a]!.vector, a * dims); | |
| } | |
| _axesCache = { lang, axes, axesVectors }; | |
| return axes; | |
| } | |
| /** Get cached axes vectors for dot product computation. */ | |
| export function getCachedAxesVectors(): Float32Array | null { | |
| return _axesCache?.axesVectors ?? null; | |
| } | |
| /** Get cached axis names in order. */ | |
| export function getCachedAxesNames(): string[] { | |
| return _axesCache?.axes.map((a) => a.name) ?? []; | |
| } | |
| const _axesCache = new Map<string, { axes: AxisData[]; axesVectors: Float32Array }>(); | |
| /** | |
| * Load axis data from DB. Called once at startup, cached forever. | |
| * Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible. | |
| */ | |
| export async function loadAxes(lang: string = 'en'): Promise<AxisData[]> { | |
| const cached = _axesCache.get(lang); | |
| if (cached) return cached.axes; | |
| const rows = await prisma.$queryRaw< | |
| Array<{ | |
| name: string; | |
| low_anchor: string; | |
| high_anchor: string; | |
| vector: string; // pgvector returns as string "[0.1,0.2,...]" | |
| auc: number | null; | |
| range_p5: number | null; | |
| range_p95: number | null; | |
| }> | |
| >`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95 | |
| FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`; | |
| const axes: AxisData[] = rows.map((r) => ({ | |
| name: r.name, | |
| lowAnchor: r.low_anchor, | |
| highAnchor: r.high_anchor, | |
| vector: parseVector(r.vector), | |
| auc: r.auc ?? 0, | |
| rangeP5: r.range_p5 ?? 0, | |
| rangeP95: r.range_p95 ?? 0, | |
| })); | |
| // Build concatenated axes vector array for fast dot products | |
| const dims = axes[0]?.vector.length ?? 512; | |
| const axesVectors = new Float32Array(axes.length * dims); | |
| for (let a = 0; a < axes.length; a++) { | |
| axesVectors.set(axes[a]!.vector, a * dims); | |
| } | |
| _axesCache.set(lang, { axes, axesVectors }); | |
| return axes; | |
| } | |
| /** Get cached axes vectors for dot product computation. */ | |
| export function getCachedAxesVectors(lang: string): Float32Array | null { | |
| return _axesCache.get(lang)?.axesVectors ?? null; | |
| } | |
| /** Get cached axis names in order. */ | |
| export function getCachedAxesNames(lang: string): string[] { | |
| return _axesCache.get(lang)?.axes.map((a) => a.name) ?? []; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/utils/semantic-db.ts` around lines 34 - 85, The cache currently holds
a single {lang, axes, axesVectors} and races across languages; change it to a
per-language cache (e.g., Map<string, { axes: AxisData[]; axesVectors:
Float32Array }>) in place of _axesCache, store entries keyed by lang inside
loadAxes, and update the getters getCachedAxesVectors and getCachedAxesNames to
accept a lang parameter and return the cached entry for that lang (or
null/empty). Also search for and update any call sites that call
getCachedAxesVectors() / getCachedAxesNames() to pass the current language so
callers get the correct per-language data.
- Removed SEMANTIC_DB/SEMANTIC_LEGACY feature flags — DB is the only path now. No in-memory 98MB embedding matrix. - Warmup plugin loads only axes (140KB) + valid words (2MB) - guess.post.ts: clean single-path, Promise.all, no duplication - reveal.post.ts: batch rank query (1 query instead of N+1) - tests/db-cache.test.ts: interface tests, type safety, graceful null - tests/semantic-db.test.ts: rank correctness, kNN, axes, caching - scripts/benchmark-semantic-db.ts: p50/p95/p99 latency benchmarks - Fixed remaining loadWordStats await callers
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/utils/db-cache.ts`:
- Around line 121-152: The incrementWordStats function currently swallows DB
errors in its try/catch (around the prisma.$executeRaw call) which loses
updates; change the function to surface failures so callers can retry or fall
back: update the signature of incrementWordStats to return Promise<boolean>
(true on success, false on failure) or throw the caught error instead of just
console.warn; inside the catch for prisma.$executeRaw, log the error with
context and then either return false or rethrow the error so the caller can
trigger the legacy JSON path or retry logic.
- Around line 61-73: The upsert currently spreads ...data into the create/update
objects (in prisma.definition.upsert and the similar upsert that sets model
metadata), which omits undefined fields and leaves stale nullable columns;
update the upsert payloads in db-cache.ts (the prisma.definition.upsert call and
the upsert that handles model metadata) to explicitly set each nullable column
to either its value or null (e.g., definitionText: data.definitionText ?? null,
model: modelValue ?? null) instead of relying on ...data so nulls are written
and old values are cleared; ensure cachedAt and isNegative remain set as before.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: dbdd1b28-80c8-4b0f-abdf-148033b9931d
📒 Files selected for processing (5)
components/app/AppSidebar.vuecomponents/game/MultiBoardLayout.vuepages/[lang]/semantic.vueserver/utils/db-cache.tsserver/utils/semantic-db.ts
✅ Files skipped from review due to trivial changes (1)
- server/utils/semantic-db.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- pages/[lang]/semantic.vue
| await prisma.definition.upsert({ | ||
| where: { lang_word: { lang, word } }, | ||
| create: { | ||
| lang, | ||
| word, | ||
| ...data, | ||
| isNegative, | ||
| }, | ||
| update: { | ||
| ...data, | ||
| isNegative, | ||
| cachedAt: new Date(), | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Prisma Client 7.x, when an updateorupsertfield isundefined, is that field omitted from the SQL statement while null writes SQL NULL?
💡 Result:
Yes, in Prisma Client 7.x, when an update or upsert field's value is undefined, that field is omitted from the generated SQL statement. When the value is null, Prisma writes SQL NULL to explicitly set the field to null in the database. This behavior is the default ("current behavior") in Prisma 7.x and has not changed from previous major versions unless the strictUndefinedChecks preview feature is explicitly enabled in the schema. With strictUndefinedChecks enabled, explicitly passing undefined throws a runtime error, and Prisma.skip must be used instead to omit fields. Official Prisma documentation confirms: "In Prisma ORM, if undefined is passed as a value, it is not included in the generated query" and "Prisma Client differentiates between null and undefined: - null is a value - undefined means do nothing". This applies to update operations, as shown in the GraphQL resolver example where setting a field to undefined in update.data omits it, while null sets it to SQL NULL. No changes to this core behavior appear in Prisma 7.x release notes or upgrade guides.
Citations:
- 1: https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined
- 2: https://www.prisma.io/docs/v6/orm/prisma-client/special-fields-and-types/null-and-undefined
- 3: https://prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined
- 4: https://docs.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined
- 5: https://stackoverflow.com/questions/69526209/prisma-how-can-i-update-only-some-of-the-models-fields-in-update
🏁 Script executed:
# Check Prisma version in package.json or package-lock.json
if [ -f package.json ]; then
echo "=== package.json ==="
grep -A 2 '"prisma"' package.json || echo "Not found in package.json"
fi
if [ -f package-lock.json ]; then
echo "=== package-lock.json ==="
grep -A 2 '"prisma"' package-lock.json | head -20
fiRepository: Hugo0/wordle
Length of output: 164
🏁 Script executed:
# Check the actual db-cache.ts file
find . -name "db-cache.ts" -type f | head -5Repository: Hugo0/wordle
Length of output: 82
🏁 Script executed:
# Read the db-cache.ts file
cat -n server/utils/db-cache.ts | head -100Repository: Hugo0/wordle
Length of output: 3672
🏁 Script executed:
# Get more context around lines 61-73 and 208-211
cat -n server/utils/db-cache.ts | sed -n '50,80p'Repository: Hugo0/wordle
Length of output: 1019
🏁 Script executed:
# Check lines around 208-211
cat -n server/utils/db-cache.ts | sed -n '200,220p'Repository: Hugo0/wordle
Length of output: 575
Explicitly clear nullable fields on cache overwrites.
When spreading ...data with undefined values in line 70, those fields are omitted from the SQL statement. This causes a positive→negative definition transition to retain the old definition text. Similarly, line 211 omits model when undefined, leaving stale model metadata. Explicitly set nullable fields to null (or their values) to ensure they're included in the update clause:
Proposed fix
export async function upsertDefinition(
lang: string,
word: string,
data: DefinitionData,
isNegative = false
): Promise<void> {
+ const definitionFields = isNegative
+ ? {
+ definition: null,
+ definitionNative: null,
+ definitionEn: null,
+ partOfSpeech: null,
+ confidence: null,
+ source: null,
+ url: null,
+ }
+ : {
+ definition: data.definition ?? null,
+ definitionNative: data.definitionNative ?? null,
+ definitionEn: data.definitionEn ?? null,
+ partOfSpeech: data.partOfSpeech ?? null,
+ confidence: data.confidence ?? null,
+ source: data.source ?? null,
+ url: data.url ?? null,
+ };
try {
await prisma.definition.upsert({
where: { lang_word: { lang, word } },
create: {
lang,
word,
- ...data,
+ ...definitionFields,
isNegative,
},
update: {
- ...data,
+ ...definitionFields,
isNegative,
cachedAt: new Date(),
},
});
} catch {
// Non-critical — disk fallback still works
}
}
@@
export async function setSemanticHint(
lang: string,
word: string,
hint: string,
model?: string
): Promise<void> {
try {
await prisma.semanticHint.upsert({
where: { lang_word: { lang, word } },
- create: { lang, word, hint, model },
- update: { hint, model, cachedAt: new Date() },
+ create: { lang, word, hint, model: model ?? null },
+ update: { hint, model: model ?? null, cachedAt: new Date() },
});
} catch {
// Non-critical
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/utils/db-cache.ts` around lines 61 - 73, The upsert currently spreads
...data into the create/update objects (in prisma.definition.upsert and the
similar upsert that sets model metadata), which omits undefined fields and
leaves stale nullable columns; update the upsert payloads in db-cache.ts (the
prisma.definition.upsert call and the upsert that handles model metadata) to
explicitly set each nullable column to either its value or null (e.g.,
definitionText: data.definitionText ?? null, model: modelValue ?? null) instead
of relying on ...data so nulls are written and old values are cleared; ensure
cachedAt and isNegative remain set as before.
…, disk fallback - guess.post.ts: normalize axis projections using p5/p95 ranges (was returning raw dot products, breaking client rendering) - definitions.ts: wrap db-cache import in try/catch so disk/LLM fallback tiers still run when DB module fails to load - db-cache.ts: incrementWordStats returns boolean for success/failure - word-stats.ts: falls back to disk when DB write returns false - semantic-db.ts: export getCachedAxes() for normalization data
MeaningMap: - Remove inline width/height on .canvas-wrap and <svg> — CSS controls display size, viewBox (520) controls coordinate space - width:520px in CSS (shrinkable), aspect-ratio:1, max-width:100% - Expanded mode: :global(.map-expanded) fills 90dvh/95dvw - SVG uses width:100% height:100% — scales from container Semantic page: - :deep(.canvas-wrap) max-width constrains map to available height on short viewports (the square sizes from min of width/height) - Hide "Find the hidden word" header on viewports <700px tall to give map more space (--map-chrome adjusts from 310→210px) - editorial-scroll on .semantic-body TODO: - #16: viewport-locked semantic layout (full refactor, deferred)
Previously pan offset was mixed into the polar projection computation, causing every dot's angle to change when dragging. Zoom divided the radius, changing dot spacing. Now: - Dot positions computed with fixed center (no pan/zoom in math) - Pan/zoom applied as SVG <g transform="..."> wrapping all content - Grid, dots, target, connectors all move as a rigid unit - Target always at canvas center (transform handles the shift) This fixes: dots repositioning during drag, zoom changing relative positions, all words clustering in same spot.
…duced regressions The clumping bug is a DATA issue (umapPosition fallback to [0.5,0.5]), not a rendering issue. My changes to CSS sizing and polar projection math introduced: grid drifting from dots, viewport transform breaking the coordinate system, and expanded view not sizing correctly. Proper map refactor deferred to TODO #16 with full architecture plan.
- Extract readCachedWord() helper, reuse in getWordForDay + recency loop - Expand disk cache guard to accept mode slot indices (not just classic) so cached words feed the 60-day recency exclusion window - All modes (dordle, quordle, speed, etc.) now avoid repeating words within RECENCY_WINDOW days, same as classic
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
server/utils/word-selection.ts (1)
364-373:⚠️ Potential issue | 🟠 MajorDistinctness is not guaranteed when probe budget is exhausted.
If
MAX_PROBEis reached, the current flow still pusheswordeven when it is already inused, so duplicates can leak into modes that require unique words.Suggested fix
while (used.has(word) && probe < MAX_PROBE) { word = getWordForDay(langCode, slotIdx + probe); probe++; } + if (used.has(word)) { + throw new Error( + `[word-selection] Could not find unique word for mode=${mode}, slotIdx=${slotIdx}, count=${count}` + ); + } + used.add(word); words.push(word);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/word-selection.ts` around lines 364 - 373, The dedup probe loop around getWordForDay can exhaust MAX_PROBE and still push a duplicate into words; update the logic in the word selection routine so after the while loop you check whether used.has(word) is still true (i.e., probe >= MAX_PROBE) and handle it explicitly: either throw a clear error, log and skip this slot, or continue a fallback linear scan for the next unused word; reference the variables/functions used, getWordForDay, probe, MAX_PROBE, used.add(word), and words.push(word) and ensure you never call used.add or words.push with a word that is already in used.
♻️ Duplicate comments (1)
server/utils/db-cache.ts (1)
203-217:⚠️ Potential issue | 🟡 MinorOptional
modelfield may retain stale values.When
modelisundefined(line 212-213), Prisma omits it from bothcreateandupdate. On update, this means the oldmodelvalue persists even if the caller intended to clear it.Proposed fix
try { await prisma.semanticHint.upsert({ where: { lang_word: { lang, word } }, - create: { lang, word, hint, model }, - update: { hint, model, cachedAt: new Date() }, + create: { lang, word, hint, model: model ?? null }, + update: { hint, model: model ?? null, cachedAt: new Date() }, }); } catch {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/db-cache.ts` around lines 203 - 217, The upsert in setSemanticHint can leave an old model value when model is undefined; change the create/update payloads to explicitly set model to null when the caller passes undefined (e.g., use model: typeof model === "undefined" ? null : model in both the create and update objects) so an intended clear will overwrite any prior value; update the references inside setSemanticHint to use this explicit nulling.
🧹 Nitpick comments (3)
scripts/benchmark-semantic-db.ts (1)
14-17: Remove unused Prisma imports.
PrismaandPrismaPgare imported but never used in this script. The benchmark uses rawpgqueries directly.Proposed fix
import pg from 'pg'; -import Prisma from '@prisma/client'; -import { PrismaPg } from '@prisma/adapter-pg'; - -const { PrismaClient } = Prisma;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/benchmark-semantic-db.ts` around lines 14 - 17, Remove the unused Prisma-related imports: delete the import of "Prisma" and "PrismaPg" and the destructuring of "PrismaClient" since this script uses raw pg queries only; specifically remove the lines that import Prisma from '@prisma/client', import { PrismaPg } from '@prisma/adapter-pg', and the const { PrismaClient } = Prisma statement so only the actual pg-based imports remain.tests/db-cache.test.ts (2)
67-80: Test duplicates logic instead of testing the function.This test replicates the conditional logic with hardcoded values (
true && 1 === 1) rather than exercising the actualincrementWordStatsfunction. The self-comparisons are always true, making this test effectively a no-op.Consider either:
- Testing
incrementWordStatsdirectly against the DB (in the conditional DB-available block)- Removing this test since it validates nothing about the real implementation
Alternative: Extract and test the logic as a pure function
- it('incrementWordStats computes correct dist columns', () => { - // Verify the logic: won=true + attempts=3 should increment dist_3 - // This tests the function's internal logic without DB - const d1 = true && 1 === 1 ? 1 : 0; // attempts=1 - const d3 = true && 3 === 3 ? 1 : 0; // attempts=3 - const d6 = true && 6 === 6 ? 1 : 0; // attempts=6 - const d7 = true && 7 === 7 ? 1 : 0; // attempts=7 (out of range) - expect(d1).toBe(1); - expect(d3).toBe(1); - expect(d6).toBe(1); - // attempts=7 should still be 1 by the conditional, but the - // actual function clamps to 1-6 via the individual checks - expect(d7).toBe(1); // The guard is in the function, not here - }); + it('incrementWordStats computes correct dist columns', () => { + // Helper to compute dist flags (mirrors function logic) + const computeDist = (won: boolean, attempts: number) => ({ + d1: won && attempts === 1 ? 1 : 0, + d2: won && attempts === 2 ? 1 : 0, + d3: won && attempts === 3 ? 1 : 0, + d4: won && attempts === 4 ? 1 : 0, + d5: won && attempts === 5 ? 1 : 0, + d6: won && attempts === 6 ? 1 : 0, + }); + + expect(computeDist(true, 1)).toEqual({ d1: 1, d2: 0, d3: 0, d4: 0, d5: 0, d6: 0 }); + expect(computeDist(true, 3)).toEqual({ d1: 0, d2: 0, d3: 1, d4: 0, d5: 0, d6: 0 }); + expect(computeDist(true, 7)).toEqual({ d1: 0, d2: 0, d3: 0, d4: 0, d5: 0, d6: 0 }); // out of range + expect(computeDist(false, 3)).toEqual({ d1: 0, d2: 0, d3: 0, d4: 0, d5: 0, d6: 0 }); // lost + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/db-cache.test.ts` around lines 67 - 80, The test currently duplicates the conditional logic with hardcoded expressions instead of invoking the real code; replace the no-op assertions by either calling incrementWordStats with representative inputs and asserting the produced dist columns (or DB-updated values) inside the existing DB-available test block, or remove this test entirely; alternatively extract the dist-calculation into a pure helper (e.g., computeDistBucket or the internal logic used by incrementWordStats), import and unit-test that helper directly—use the function name incrementWordStats (or the new helper) to locate and update the test.
10-18: Simplify DATABASE_URL check.The try/catch is unnecessary since accessing
process.env.DATABASE_URLdoesn't throw.Proposed simplification
// Check if DB is available -let dbAvailable = false; -try { - if (process.env.DATABASE_URL) { - dbAvailable = true; - } -} catch { - dbAvailable = false; -} +const dbAvailable = !!process.env.DATABASE_URL;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/db-cache.test.ts` around lines 10 - 18, The try/catch around checking process.env.DATABASE_URL is unnecessary; simplify the logic that sets dbAvailable by directly assigning it based on the presence/truthiness of process.env.DATABASE_URL (replace the try/catch block that manipulates dbAvailable with a direct boolean assignment), referencing the dbAvailable variable and the environment lookup process.env.DATABASE_URL to locate and update the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/utils/word-selection.ts`:
- Around line 352-355: The code currently defaults unknown/typo modes to offset
0 via MODE_SLOT_OFFSETS[mode] ?? 0 causing silent overlap with classic; update
the function to validate that mode exists in MODE_SLOT_OFFSETS before computing
baseIdx (using the mode parameter and MODE_SLOT_OFFSETS lookup) and throw a
clear Error (including the invalid mode) when it's not present so callers fail
closed instead of silently using the classic offset.
---
Outside diff comments:
In `@server/utils/word-selection.ts`:
- Around line 364-373: The dedup probe loop around getWordForDay can exhaust
MAX_PROBE and still push a duplicate into words; update the logic in the word
selection routine so after the while loop you check whether used.has(word) is
still true (i.e., probe >= MAX_PROBE) and handle it explicitly: either throw a
clear error, log and skip this slot, or continue a fallback linear scan for the
next unused word; reference the variables/functions used, getWordForDay, probe,
MAX_PROBE, used.add(word), and words.push(word) and ensure you never call
used.add or words.push with a word that is already in used.
---
Duplicate comments:
In `@server/utils/db-cache.ts`:
- Around line 203-217: The upsert in setSemanticHint can leave an old model
value when model is undefined; change the create/update payloads to explicitly
set model to null when the caller passes undefined (e.g., use model: typeof
model === "undefined" ? null : model in both the create and update objects) so
an intended clear will overwrite any prior value; update the references inside
setSemanticHint to use this explicit nulling.
---
Nitpick comments:
In `@scripts/benchmark-semantic-db.ts`:
- Around line 14-17: Remove the unused Prisma-related imports: delete the import
of "Prisma" and "PrismaPg" and the destructuring of "PrismaClient" since this
script uses raw pg queries only; specifically remove the lines that import
Prisma from '@prisma/client', import { PrismaPg } from '@prisma/adapter-pg', and
the const { PrismaClient } = Prisma statement so only the actual pg-based
imports remain.
In `@tests/db-cache.test.ts`:
- Around line 67-80: The test currently duplicates the conditional logic with
hardcoded expressions instead of invoking the real code; replace the no-op
assertions by either calling incrementWordStats with representative inputs and
asserting the produced dist columns (or DB-updated values) inside the existing
DB-available test block, or remove this test entirely; alternatively extract the
dist-calculation into a pure helper (e.g., computeDistBucket or the internal
logic used by incrementWordStats), import and unit-test that helper directly—use
the function name incrementWordStats (or the new helper) to locate and update
the test.
- Around line 10-18: The try/catch around checking process.env.DATABASE_URL is
unnecessary; simplify the logic that sets dbAvailable by directly assigning it
based on the presence/truthiness of process.env.DATABASE_URL (replace the
try/catch block that manipulates dbAvailable with a direct boolean assignment),
referencing the dbAvailable variable and the environment lookup
process.env.DATABASE_URL to locate and update the code.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 85f0cb84-ba30-46de-a7ca-553ea281faed
📒 Files selected for processing (14)
TODO.mdpages/[lang]/semantic.vuescripts/benchmark-semantic-db.tsserver/api/[lang]/data.get.tsserver/api/[lang]/semantic/guess.post.tsserver/api/[lang]/semantic/reveal.post.tsserver/plugins/semantic-warmup.tsserver/utils/db-cache.tsserver/utils/definitions.tsserver/utils/semantic-db.tsserver/utils/word-selection.tsserver/utils/word-stats.tstests/db-cache.test.tstests/semantic-db.test.ts
✅ Files skipped from review due to trivial changes (1)
- TODO.md
🚧 Files skipped from review as they are similar to previous changes (4)
- server/api/[lang]/data.get.ts
- server/utils/word-stats.ts
- pages/[lang]/semantic.vue
- server/api/[lang]/semantic/guess.post.ts
| mode: string = 'classic' | ||
| ): string[] { | ||
| const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0; | ||
| const baseIdx = todaysIdx + modeOffset; |
There was a problem hiding this comment.
Fail closed on unknown mode instead of defaulting to classic offset.
At Line 354, unsupported/typo modes silently map to 0, which can unintentionally overlap with the classic region and mask config/caller bugs.
Suggested fix
export function getWordsForDay(
langCode: string,
todaysIdx: number,
count: number,
mode: string = 'classic'
): string[] {
- const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0;
+ const modeOffset = MODE_SLOT_OFFSETS[mode];
+ if (modeOffset === undefined) {
+ throw new Error(`[word-selection] Unsupported mode "${mode}"`);
+ }
const baseIdx = todaysIdx + modeOffset;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| mode: string = 'classic' | |
| ): string[] { | |
| const modeOffset = MODE_SLOT_OFFSETS[mode] ?? 0; | |
| const baseIdx = todaysIdx + modeOffset; | |
| export function getWordsForDay( | |
| langCode: string, | |
| todaysIdx: number, | |
| count: number, | |
| mode: string = 'classic' | |
| ): string[] { | |
| const modeOffset = MODE_SLOT_OFFSETS[mode]; | |
| if (modeOffset === undefined) { | |
| throw new Error(`[word-selection] Unsupported mode "${mode}"`); | |
| } | |
| const baseIdx = todaysIdx + modeOffset; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@server/utils/word-selection.ts` around lines 352 - 355, The code currently
defaults unknown/typo modes to offset 0 via MODE_SLOT_OFFSETS[mode] ?? 0 causing
silent overlap with classic; update the function to validate that mode exists in
MODE_SLOT_OFFSETS before computing baseIdx (using the mode parameter and
MODE_SLOT_OFFSETS lookup) and throw a clear Error (including the invalid mode)
when it's not present so callers fail closed instead of silently using the
classic offset.
Add server/utils/inflight.ts — a lightweight in-flight request coalescing utility. When multiple concurrent requests need the same expensive result, only the first caller runs the function; others await the same Promise. Wired into all three LLM call paths: - definitions: concurrent requests for same lang:word share one GPT call - semantic hints: concurrent hint requests share one generation+validation - on-demand embeddings: concurrent OOV word lookups share one API call Also includes other agents' work: - MeaningMap ResizeObserver for proper display scaling - DB migration script cleanup + DEPRECATED warnings on disk fallback paths - TODO.md tracking for disk fallback removal
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (1)
server/api/[lang]/semantic/hint.post.ts (1)
157-159:⚠️ Potential issue | 🟠 MajorScope the disk fallback by language too.
The DB and in-flight tiers are keyed by
(lang, target), but the disk tier is still keyed only bytarget. When the DB path misses or is unavailable, this can return a hint generated for another language and reintroduces the same cross-language collision as before.Suggested fix
- mkdirSync(CACHE_DIR, { recursive: true }); - const cacheFile = join(CACHE_DIR, `${target}.json`); + const langCacheDir = join(CACHE_DIR, lang); + mkdirSync(langCacheDir, { recursive: true }); + const cacheFile = join(langCacheDir, `${target}.json`);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/`[lang]/semantic/hint.post.ts around lines 157 - 159, Disk cache is currently keyed only by `target` (CACHE_DIR + `${target}.json`) causing cross-language collisions; change disk cache keys to include `lang` as well. Update the creation and lookup of `cacheFile` in server/api/[lang]/semantic/hint.post.ts (where `mkdirSync`, `CACHE_DIR`, and `join` are used) to scope by language (e.g., use a language subdirectory or include `lang` in the filename like `${lang}-${target}.json`), ensure the directory for that language is created with `mkdirSync`, and adjust all reads/writes that reference `cacheFile` so they use the new language-scoped path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@components/shared/MeaningMap.vue`:
- Around line 114-129: The pan math is still using raw screen pixels so dragging
is wrong when the SVG is scaled; update all conversions between screen/pointer
pixels and viewBox pixels to use the computed displayScale (multiply/divide by
displayScale as appropriate) wherever panOffset is set or applied (search for
panOffset assignments/uses and pointer handlers like onPointerDown/onPointerMove
or functions that read svgRef event coords around the previously mentioned
spots), ensuring pointer-derived deltas are divided by displayScale to convert
to viewBox units and that any viewBox->screen calculations multiply by
displayScale.
- Line 396: The component calls onMounted(() => _initResizeObserver()) but
onMounted was not imported; add onMounted to the Vue imports in MeaningMap.vue
(i.e., include onMounted in the existing import { ... } from 'vue' so the
onMounted symbol is available for use with the _initResizeObserver call).
In `@scripts/migrate-caches-to-db.ts`:
- Around line 35-39: The pg.Pool is forcing TLS by setting ssl: {
rejectUnauthorized: false } unconditionally, which breaks connections to servers
that don't support SSL; update the pool creation (new pg.Pool / pool) to only
set the ssl option when configured (e.g., driven by an env var or parsed from
DATABASE_URL) so local/CI setups can omit SSL and production can enable
it—change the logic that builds the Pool config to conditionally include ssl
instead of hard-coding it.
In `@server/utils/definitions.ts`:
- Around line 348-390: The dedup closure currently ignores options.cacheOnly so
a cache-only miss can be negative-cached and block concurrent full lookups; fix
by including the cacheOnly flag in the dedup key (e.g., append
`:cacheOnly=${options.cacheOnly}` to `dedupKey`) or, alternatively, avoid
persisting negative results when `options.cacheOnly` is true by skipping the
dbCache.upsertDefinition / disk write for `isNeg` if `options.cacheOnly` is set;
update the usage around dedup('definition', dedupKey, ...), options.cacheOnly,
and dbCache.upsertDefinition/writeFileSync accordingly.
- Around line 317-321: The console.warn for "[DEPRECATED] definitions disk
fallback hit" is emitted before checking whether the disk file actually exists;
move the warning so it runs only when the disk fallback is real by checking
existsSync(cachePath) first (use the same cachePath variable created from
WORD_DEFS_DIR, join(langCode, ...) and word.toLowerCase()), and only if
existsSync(cachePath) returns true emit the warning and proceed with reading the
file; ensure no warning is logged when the DB miss occurs and no disk file is
present.
In `@server/utils/word-stats.ts`:
- Around line 30-33: The deprecated warning is logged on every DB miss even if
the disk file doesn't exist; update the code around WORD_STATS_DIR/statsPath so
you first compute statsPath and check existsSync(statsPath) and only then log
the `[DEPRECATED] word-stats disk fallback hit for` message (and proceed to
read/return the file); ensure console.warn is moved to after the existsSync
check (or only executed when a successful disk read occurs) so the warning fires
only when the disk tier is actually used.
---
Duplicate comments:
In `@server/api/`[lang]/semantic/hint.post.ts:
- Around line 157-159: Disk cache is currently keyed only by `target` (CACHE_DIR
+ `${target}.json`) causing cross-language collisions; change disk cache keys to
include `lang` as well. Update the creation and lookup of `cacheFile` in
server/api/[lang]/semantic/hint.post.ts (where `mkdirSync`, `CACHE_DIR`, and
`join` are used) to scope by language (e.g., use a language subdirectory or
include `lang` in the filename like `${lang}-${target}.json`), ensure the
directory for that language is created with `mkdirSync`, and adjust all
reads/writes that reference `cacheFile` so they use the new language-scoped
path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f35e8926-ae72-415b-88e8-303c797dbbb0
📒 Files selected for processing (9)
TODO.mdcomponents/shared/MeaningMap.vuescripts/migrate-caches-to-db.tsserver/api/[lang]/semantic/hint.post.tsserver/utils/definitions.tsserver/utils/inflight.tsserver/utils/semantic.tsserver/utils/wiktionary.tsserver/utils/word-stats.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- server/utils/wiktionary.ts
| const pool = new pg.Pool({ | ||
| connectionString: DATABASE_URL, | ||
| ssl: { rejectUnauthorized: false }, | ||
| max: 5, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
With node-postgres (pg), does setting ssl: { rejectUnauthorized: false }onnew Pool() force TLS, and how does it behave when the PostgreSQL server does not support SSL?
💡 Result:
No, setting ssl: { rejectUnauthorized: false } on new Pool does not force TLS. It configures the TLS options to be used if a TLS connection is established, specifically disabling certificate verification. However, node-postgres only attempts a TLS connection if the PostgreSQL server supports SSL/TLS. If the server does not support SSL (i.e., ssl = off in PostgreSQL config), node-postgres first establishes a plain TCP connection, sends an SSL request byte during the startup protocol handshake, and the server responds with a rejection. This results in the error "The server does not support SSL connections", and the connection fails without falling back to non-TLS.
Citations:
- 1: https://node-postgres.com/features/ssl
- 2: Connection failure leaks an open socket brianc/node-postgres#2907
- 3: Connecting to databases that don't support SSL brianc/node-postgres#2572
- 4: Fallback to non-SSL connection brianc/node-postgres#2720
- 5: Unable to connect using SSL brianc/node-postgres#2934
- 6: https://stackoverflow.com/questions/54302088/how-to-fix-error-the-server-does-not-support-ssl-connections-when-trying-to-a
🏁 Script executed:
# Check if the file exists and read the relevant lines
if [ -f "scripts/migrate-caches-to-db.ts" ]; then
echo "=== File found, checking lines 30-45 ==="
sed -n '30,45p' scripts/migrate-caches-to-db.ts | cat -n
else
echo "File not found at scripts/migrate-caches-to-db.ts"
# Try to find it
fd -t f "migrate-caches-to-db" 2>/dev/null || echo "File not found in repository"
fiRepository: Hugo0/wordle
Length of output: 720
Don't hard-code TLS requirement for every migration run.
Setting ssl: { rejectUnauthorized: false } forces node-postgres to attempt a TLS connection on every run. If the PostgreSQL server doesn't support SSL, the connection fails with "The server does not support SSL connections"—no graceful fallback. This breaks the migration against valid local or CI databases that don't require TLS, making the tool environment-fragile. Gate SSL behind configuration instead.
Suggested fix
-const pool = new pg.Pool({
- connectionString: DATABASE_URL,
- ssl: { rejectUnauthorized: false },
- max: 5,
-});
+const useSsl =
+ /\bsslmode=require\b/i.test(DATABASE_URL) || process.env.PGSSLMODE === 'require';
+
+const pool = new pg.Pool({
+ connectionString: DATABASE_URL,
+ ssl: useSsl ? { rejectUnauthorized: false } : undefined,
+ max: 5,
+});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const pool = new pg.Pool({ | |
| connectionString: DATABASE_URL, | |
| ssl: { rejectUnauthorized: false }, | |
| max: 5, | |
| }); | |
| const useSsl = | |
| /\bsslmode=require\b/i.test(DATABASE_URL) || process.env.PGSSLMODE === 'require'; | |
| const pool = new pg.Pool({ | |
| connectionString: DATABASE_URL, | |
| ssl: useSsl ? { rejectUnauthorized: false } : undefined, | |
| max: 5, | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/migrate-caches-to-db.ts` around lines 35 - 39, The pg.Pool is forcing
TLS by setting ssl: { rejectUnauthorized: false } unconditionally, which breaks
connections to servers that don't support SSL; update the pool creation (new
pg.Pool / pool) to only set the ssl option when configured (e.g., driven by an
env var or parsed from DATABASE_URL) so local/CI setups can omit SSL and
production can enable it—change the logic that builds the Pool config to
conditionally include ssl instead of hard-coding it.
Adopt standard web-map pattern: world coordinates + rigid camera <g>. - screenDots computes positions at zoom=1, pan=[0,0] (world coords) - Single <g :transform="translate(...) scale(...)"> wraps all SVG content - Pan/zoom applied as rigid viewport transform — dots never reposition relative to each other during drag or zoom - Semantic zoom: dots counter-scale (scale(1/zoom)) for constant visual size - Grid in world coordinates, extends beyond [0,1] for panning coverage - vector-effect="non-scaling-stroke" on grid/connector lines - ResizeObserver tracks actual CSS display width for accurate pan conversion - Target always at (S/2, S/2) in world space - Border moved from MeaningMap to MapFrame (controls inside border) - Expanded mode passes frameSize for larger viewBox
- Add dedup() to word-image endpoint, replacing .pending file lock (cleaner, no orphaned lock files on crash) - Add dedup() to wiktionary HEAD probe - All 5 external API paths now use inflight dedup: definitions, hints, embeddings, images, wiktionary - Include other agents' DB cache fixes and semantic improvements
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (2)
server/utils/db-cache.ts (1)
61-73:⚠️ Potential issue | 🟡 MinorNormalize nullable fields before these Prisma upserts.
Both calls rely on Prisma receiving
undefinedfor omitted fields, which means old nullable values can survive an overwrite. That leaves stale definition columns and stalemodelmetadata behind when the new payload intentionally clears them.Proposed fix
export async function upsertDefinition( lang: string, word: string, data: DefinitionData, isNegative = false ): Promise<void> { + const definitionFields = { + definition: isNegative ? null : (data.definition ?? null), + definitionNative: isNegative ? null : (data.definitionNative ?? null), + definitionEn: isNegative ? null : (data.definitionEn ?? null), + partOfSpeech: isNegative ? null : (data.partOfSpeech ?? null), + confidence: isNegative ? null : (data.confidence ?? null), + source: isNegative ? null : (data.source ?? null), + url: isNegative ? null : (data.url ?? null), + }; try { await prisma.definition.upsert({ where: { lang_word: { lang, word } }, create: { lang, word, - ...data, + ...definitionFields, isNegative, }, update: { - ...data, + ...definitionFields, isNegative, cachedAt: new Date(), }, }); } catch { @@ export async function setSemanticHint( lang: string, word: string, hint: string, model?: string ): Promise<void> { try { await prisma.semanticHint.upsert({ where: { lang_word: { lang, word } }, - create: { lang, word, hint, model }, - update: { hint, model, cachedAt: new Date() }, + create: { lang, word, hint, model: model ?? null }, + update: { hint, model: model ?? null, cachedAt: new Date() }, }); } catch { // Non-critical } }Also applies to: 210-213
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/db-cache.ts` around lines 61 - 73, The upsert calls to prisma.definition.upsert (the where: { lang_word: { lang, word } } blocks using create/update) must normalize nullable fields to undefined so intentional clears don't leave old values; before calling prisma.definition.upsert (and the other upsert at the same file region), map any nullable properties (e.g., model, any optional metadata fields, and other nullable columns included in ...data) and convert explicit nulls or missing keys to undefined, then pass that sanitized object into the create and update payloads so Prisma will remove stale values instead of preserving them.server/utils/definitions.ts (1)
339-381:⚠️ Potential issue | 🟠 MajorKeep
cacheOnlymisses out of the shared negative cache.The dedup key still ignores
cacheOnly, and this closure still persists a miss. A cache-only request can therefore negative-cache the word and make later full lookups returnnullwithout ever trying the LLM.Proposed fix
- const dedupKey = `${langCode}:${word.toLowerCase()}`; + const cacheKey = word.toLowerCase(); + const allowLlm = !options.cacheOnly; + const dedupKey = `${langCode}:${cacheKey}:${allowLlm ? 'llm' : 'cache-only'}`; const result = await dedup('definition', dedupKey, async () => { let llmResult: Record<string, any> | null = null; - if (!options.cacheOnly) { + if (allowLlm) { llmResult = await callLlmDefinition(word, langCode); } if (!llmResult) { llmResult = lookupKaikki(word, langCode, 'native') || lookupKaikki(word, langCode, 'en'); } // Cache result to DB (primary) and disk (backup) - const isNeg = !llmResult; - dbCache?.upsertDefinition( - langCode, - word.toLowerCase(), - llmResult - ? { - definition: llmResult.definition, - definitionNative: llmResult.definition_native, - definitionEn: llmResult.definition_en, - partOfSpeech: llmResult.part_of_speech, - confidence: llmResult.confidence, - source: llmResult.source, - url: llmResult.url, - } - : {}, - isNeg - ); + const shouldPersistNegative = allowLlm; + const isNeg = !llmResult && shouldPersistNegative; + if (llmResult || shouldPersistNegative) { + await dbCache?.upsertDefinition( + langCode, + cacheKey, + llmResult + ? { + definition: llmResult.definition, + definitionNative: llmResult.definition_native, + definitionEn: llmResult.definition_en, + partOfSpeech: llmResult.part_of_speech, + confidence: llmResult.confidence, + source: llmResult.source, + url: llmResult.url, + } + : {}, + isNeg + ); + } // DEPRECATED: disk write — remove after confirming DB migration is stable try { mkdirSync(langCacheDir, { recursive: true }); - writeFileSync( - cachePath, - JSON.stringify(llmResult || { not_found: true, ts: Math.floor(Date.now() / 1000) }), - 'utf-8' - ); + if (llmResult || shouldPersistNegative) { + writeFileSync( + cachePath, + JSON.stringify( + llmResult || { not_found: true, ts: Math.floor(Date.now() / 1000) } + ), + 'utf-8' + ); + } } catch { // Non-critical }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/definitions.ts` around lines 339 - 381, The closure currently uses a dedup key that ignores options.cacheOnly and then persists misses, so cache-only requests can negative-cache results; fix by (1) incorporating options.cacheOnly into the dedup key (e.g., include `${options.cacheOnly}` when building dedupKey used in dedup('definition', dedupKey,...)) so cache-only lookups are deduped separately, and (2) avoid writing negative cache entries for cache-only requests by skipping the negative upsert/backup write when options.cacheOnly is true (check options.cacheOnly before calling dbCache.upsertDefinition with isNeg or before the disk write).
🧹 Nitpick comments (1)
server/api/[lang]/word-image/[word].get.ts (1)
138-145: Consider making cache write failures non-fatal.If
mkdirSyncorwriteFileSyncthrows (disk full, permissions issue), the successfully generated image is lost and the client gets a 500. The image would need to be regenerated on retry (additional DALL-E cost).You could optionally wrap the cache write in a try/catch to still return the successful image even when caching fails:
♻️ Optional: Return image even if caching fails
const sharp = (await import('sharp')).default; const buf = await sharp(imageBuffer).webp({ quality: 80 }).toBuffer(); - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cachePath, buf); + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cachePath, buf); + } catch (e) { + console.error(`Failed to cache image for ${lang}:${word}`, e); + } return buf; });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/api/`[lang]/word-image/[word].get.ts around lines 138 - 145, The cache write using mkdirSync(cacheDir, { recursive: true }) and writeFileSync(cachePath, buf) can throw and make the request return 500; wrap the cache write in a try/catch so any filesystem errors are swallowed (or logged) and do not prevent returning the generated image (webpBuffer); ensure you still set the response headers (setResponseHeader calls) and return webpBuffer even if the catch is hit, optionally logging the error with context (cacheDir/cachePath) for debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@server/utils/db-cache.ts`:
- Around line 121-149: The function incrementWordStats currently increments wins
even when won===true but attempts is outside 1..6; add an early validation in
incrementWordStats that if won is true and attempts is not an integer between 1
and 6, the call is rejected (either return false or throw an error) before
computing d1..d6 or executing the SQL so wins cannot diverge from the dist_*
sum; update any callers/tests if they expect a throw vs false. Ensure the guard
references the function name incrementWordStats and the won/attempts parameters
so the SQL block (the prisma.$executeRaw call and d1..d6 computation) never runs
for invalid attempts.
In `@tests/db-cache.test.ts`:
- Around line 66-79: The test currently asserts static constants instead of
exercising the real logic; update tests to call incrementWordStats (or extract
and call the pure bucket-mapping helper, e.g., computeDistBucket or similar) and
assert outcomes rather than constants. Either (A) import and invoke
incrementWordStats with won=true/attempts=1/3/6/7 and mock prisma.$executeRaw to
verify the generated SQL/parameters update the correct dist_1..dist_6 columns,
or (B) extract the bucket mapping into a pure function (name it clearly) in the
production module, call that function from the test with those attempt values
and assert the returned bucket index/clamped value; use jest mocks/spies on
prisma.$executeRaw when testing increments so the test actually verifies
production behavior.
- Around line 81-86: The current assertion is tautological; instead, in the test
for getDefinition ensure you simulate a DB failure and assert the function
returns null. Modify the test that imports ../server/utils/db-cache and before
calling getDefinition('test','nonexistent') mock/stub the underlying DB client
used by that module (the same dependency that db-cache calls) to throw or return
an error, then await mod.getDefinition(...) and assert expect(def).toBeNull();
keep the test name and flow but replace the current lax assertion with this
explicit null check.
---
Duplicate comments:
In `@server/utils/db-cache.ts`:
- Around line 61-73: The upsert calls to prisma.definition.upsert (the where: {
lang_word: { lang, word } } blocks using create/update) must normalize nullable
fields to undefined so intentional clears don't leave old values; before calling
prisma.definition.upsert (and the other upsert at the same file region), map any
nullable properties (e.g., model, any optional metadata fields, and other
nullable columns included in ...data) and convert explicit nulls or missing keys
to undefined, then pass that sanitized object into the create and update
payloads so Prisma will remove stale values instead of preserving them.
In `@server/utils/definitions.ts`:
- Around line 339-381: The closure currently uses a dedup key that ignores
options.cacheOnly and then persists misses, so cache-only requests can
negative-cache results; fix by (1) incorporating options.cacheOnly into the
dedup key (e.g., include `${options.cacheOnly}` when building dedupKey used in
dedup('definition', dedupKey,...)) so cache-only lookups are deduped separately,
and (2) avoid writing negative cache entries for cache-only requests by skipping
the negative upsert/backup write when options.cacheOnly is true (check
options.cacheOnly before calling dbCache.upsertDefinition with isNeg or before
the disk write).
---
Nitpick comments:
In `@server/api/`[lang]/word-image/[word].get.ts:
- Around line 138-145: The cache write using mkdirSync(cacheDir, { recursive:
true }) and writeFileSync(cachePath, buf) can throw and make the request return
500; wrap the cache write in a try/catch so any filesystem errors are swallowed
(or logged) and do not prevent returning the generated image (webpBuffer);
ensure you still set the response headers (setResponseHeader calls) and return
webpBuffer even if the catch is hit, optionally logging the error with context
(cacheDir/cachePath) for debugging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b74b19fb-b0d7-4a82-a313-b1f0b805a769
📒 Files selected for processing (12)
components/shared/MapFrame.vuecomponents/shared/MeaningMap.vuenuxt.config.tspages/[lang]/semantic.vueserver/api/[lang]/semantic/guess.post.tsserver/api/[lang]/semantic/hint.post.tsserver/api/[lang]/word-image/[word].get.tsserver/utils/db-cache.tsserver/utils/definitions.tsserver/utils/wiktionary.tsserver/utils/word-stats.tstests/db-cache.test.ts
✅ Files skipped from review due to trivial changes (1)
- components/shared/MapFrame.vue
🚧 Files skipped from review as they are similar to previous changes (5)
- server/utils/wiktionary.ts
- server/utils/word-stats.ts
- server/api/[lang]/semantic/hint.post.ts
- components/shared/MeaningMap.vue
- server/api/[lang]/semantic/guess.post.ts
| it('incrementWordStats computes correct dist columns', () => { | ||
| // Verify the logic: won=true + attempts=3 should increment dist_3 | ||
| // This tests the function's internal logic without DB | ||
| const d1 = true && 1 === 1 ? 1 : 0; // attempts=1 | ||
| const d3 = true && 3 === 3 ? 1 : 0; // attempts=3 | ||
| const d6 = true && 6 === 6 ? 1 : 0; // attempts=6 | ||
| const d7 = true && 7 === 7 ? 1 : 0; // attempts=7 (out of range) | ||
| expect(d1).toBe(1); | ||
| expect(d3).toBe(1); | ||
| expect(d6).toBe(1); | ||
| // attempts=7 should still be 1 by the conditional, but the | ||
| // actual function clamps to 1-6 via the individual checks | ||
| expect(d7).toBe(1); // The guard is in the function, not here | ||
| }); |
There was a problem hiding this comment.
This test never exercises incrementWordStats().
It only asserts constants, so it would still pass if the real bucket logic regressed, and Biome already flags the comparisons here. Please drive the production code instead—e.g. extract the bucket mapping into a pure helper or mock prisma.$executeRaw and assert a real incrementWordStats() call.
🧰 Tools
🪛 Biome (2.4.10)
[error] 69-69: Comparing to itself is potentially pointless.
(lint/suspicious/noSelfCompare)
[error] 70-70: Comparing to itself is potentially pointless.
(lint/suspicious/noSelfCompare)
[error] 71-71: Comparing to itself is potentially pointless.
(lint/suspicious/noSelfCompare)
[error] 72-72: Comparing to itself is potentially pointless.
(lint/suspicious/noSelfCompare)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/db-cache.test.ts` around lines 66 - 79, The test currently asserts
static constants instead of exercising the real logic; update tests to call
incrementWordStats (or extract and call the pure bucket-mapping helper, e.g.,
computeDistBucket or similar) and assert outcomes rather than constants. Either
(A) import and invoke incrementWordStats with won=true/attempts=1/3/6/7 and mock
prisma.$executeRaw to verify the generated SQL/parameters update the correct
dist_1..dist_6 columns, or (B) extract the bucket mapping into a pure function
(name it clearly) in the production module, call that function from the test
with those attempt values and assert the returned bucket index/clamped value;
use jest mocks/spies on prisma.$executeRaw when testing increments so the test
actually verifies production behavior.
| it('returns null gracefully when DB is unavailable', async () => { | ||
| // db-cache functions should return null (not throw) when DB errors | ||
| const mod = await import('../server/utils/db-cache'); | ||
| // These will fail silently if no DB — that's the expected behavior | ||
| const def = await mod.getDefinition('test', 'nonexistent'); | ||
| expect(def === null || def !== undefined).toBe(true); |
There was a problem hiding this comment.
The assertion here is effectively tautological.
def === null || def !== undefined passes for every resolved value except undefined, so it does not verify the “DB unavailable => null” contract. Mock a DB failure and assert the call resolves to null.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tests/db-cache.test.ts` around lines 81 - 86, The current assertion is
tautological; instead, in the test for getDefinition ensure you simulate a DB
failure and assert the function returns null. Modify the test that imports
../server/utils/db-cache and before calling getDefinition('test','nonexistent')
mock/stub the underlying DB client used by that module (the same dependency that
db-cache calls) to throw or return an error, then await mod.getDefinition(...)
and assert expect(def).toBeNull(); keep the test name and flow but replace the
current lax assertion with this explicit null check.
Fire-and-forget writes silently lose cache entries on DB errors, causing the next request to re-call the LLM. Now awaited with console.warn on failure. Only affects the cold path (inside dedup callbacks after LLM/API response), so zero latency impact on cache hits.
… load - Migrate start.post.ts, word/[slug].get.ts, word-explore/[slug].get.ts, vocab.get.ts, words.get.ts to use _semantic-db.ts (pgvector) instead of loadSemanticData() which loaded the 98MB embedding matrix into memory - Add fetchOnDemandEmbedding() to _semantic-db.ts — calls OpenAI directly, stores in DB, no SemanticData dependency - Rename semantic-db.ts → _semantic-db.ts to prevent Nitro auto-import collision with semantic.ts exports - Add model column to definitions table for source provenance tracking - Fix DEPRECATED warn placement (only log when disk is actually read) - Fix WordStats distribution type (Record<string> not Record<number>) - Build lightweight axes shim in guess.post.ts for compass (no heavy import) - Seed scripts: kaikki definitions, UMAP/PCA2D coordinates - Server startup: ~200ms (axes + valid words) instead of 3-30s (98MB parse)
prisma.$raw was silently failing, causing all UMAP positions to return null. Replaced with a single query that fetches all 4 coordinate columns, then picks the right pair in JS.
…semantic.ts definitions.ts: Remove kaikki in-memory cache (_kaikkiCache, loadKaikkiFile, lookupKaikki, resolveDefinitionsDir, DEFINITIONS_DIR), disk read/write paths, and fs imports. Kaikki definitions are pre-seeded in Postgres (253K rows). Flow is now: DB cache → LLM → done. No Tier 3, no disk I/O. semantic.ts: 982 → 230 lines. Remove loadSemanticData, loadSemanticDataSafe, getEmbedding, get2dPosition, knnNearest, getTargetDistribution, computeGuessRank, fetchEmbeddingOnDemand, projectOntoAxis, projectAllAxes, normalizeProjection, calibrateMagnitudeTiers, all file I/O, SemanticData type, EmbeddingsFile type, and all related types/constants. Keep only: cosineSimilarity, rankToDisplay, createSession, getSessionTarget, computeCompass. Fixes duplicate Nitro auto-import warnings. computeCompass now takes a lightweight typed parameter instead of SemanticData, removing the `as any` cast from guess.post.ts.
Non-game words (semantic vocab neighbors, bot-crawled paths) now use cacheOnly mode — served from DB (kaikki/cached LLM) but never trigger new OpenAI calls. Only 5-letter words in the game's wordList get fresh LLM definitions. This closes the leak that caused 9,290 unnecessary LLM definitions for English alone (~$31/day OpenAI spend).
…nsive sizing The HTML width/height attributes set intrinsic size that fights CSS. With only viewBox, the SVG is fully responsive: CSS width:100% + height:auto sizes from parent, viewBox handles the coordinate system. displayScale (ResizeObserver) converts screen pixels to viewBox coords.
- --map-chrome CSS variable (310px default, 210px on short viewports) - max-width on .map-canvas-wrap constrains the square map to available height - min-height uses min() so it never exceeds max-height - Header hidden on viewports < 700px - Input border: remove margin-top:-1px and border-top:none - Mobile: dvh instead of vh, min() on min-height
HIGH: - Path traversal in word-image endpoint — reject ../ and slashes - Rate limit LLM/DALL-E endpoints (definition 20/min, hint 10/min, DALL-E 5/hr per IP) to prevent OpenAI credit exhaustion MEDIUM: - Open redirect via auth-redirect cookie — validate relative path - bcrypt DoS via unlimited password length — cap at 128 chars - Registration displayName not validated — trim + cap at 30 chars - Debug mode leaks target word in production — restrict to dev only
… prematurely Labels are counter-scaled (constant visual size) but the overlap check was using world-space dimensions. At high zoom, world-space labels appeared huge to the algorithm and it hid most muted labels. Now scales CHAR_W and LABEL_H by invCameraScale so overlap detection matches actual visual size.
The word explorer had a 3-phase data waterfall: Phase 1 (SSR): basic word data Phase 2 (mount): word-explore → 80 neighbors (muted dots appear) Phase 3 (watch): 12× word-explore → individual foreground dot data Phase 3 fired 12 parallel HTTP requests (36-60 DB queries) just to get axis projections for the foreground dots. The UMAP coords and similarity scores were already in the phase 2 response. Fix: include axis projections for the top 15 neighbors in the explore response. One batch embedding fetch (1 DB query) + pure math on cached axis vectors. Foreground dots now render instantly from phase 2 data. - Add getEmbeddings() batch fetch to _semantic-db.ts - word-explore endpoint includes projections for top 15 neighbors - NearbyInMeaning uses neighbor projections directly (no contextData wait) - loadContextData only fetches for user-added custom words Result: 59KB single request replaces 15KB + 12×15KB = 195KB waterfall. Map renders in 1 phase instead of 3.
Includes other agents' SEO content, component updates, and language config changes alongside DB migration branch work.
…uring transitions Also early-return zoom=1 when <2 foreground dots, preventing huge zoom from single-dot min-distance calculation.
Counter-scaling (scale(1/zoom) on each dot) created timing bugs where the camera transform updated before the dot transforms, causing one frame of giant text. Without counter-scale, dots scale naturally with the camera. Labels and circles get bigger when zoomed in, smaller when zoomed out — standard map behavior (like Google Maps labels).
Counter-scale via CSS var --inv-zoom on camera <g>. WAAPI animations include invCameraScale in their keyframes so the counter-scale is never lost during bounce-in or FLIP transitions. Auto-zoom restored for absolute mode (capped at 8x). Label overlap accounts for scale.
…et word) When a session expires mid-game, gameOver=true gets saved to localStorage but finalTargetWord stays null. On reload, restoreState loads this corrupt state and the stats modal shows 'Out of guesses' with '?' even though the user can still play. Now skips restore if gameOver=true but no target word.
Semantic Explorer: - Title: "Semantic Word Game — Find the Hidden Word by Meaning" - Description targeting "contexto", "semantle", "semantic word game" - 6 FAQ items (what is it, vs Contexto/Semantle, ranks, compass, daily, guesses) - 6 HowTo steps - Tips section - Mode description for on-page SEO content Speed Streak: - Title + description - 2 FAQ items, 5 HowTo steps, tips Multiboard (dordle through duotrigordle): - Descriptions for all 5 sizes - 2 shared FAQ items Missing: og-semantic.png (needs design — currently falls back to generic)
From other agents: - Leaderboard: refactored API (streaks, records, aggregate rankings), removed standalone page (embedded in sidebar) - SEO: semantic mode meta templates in default_language_config.json, best-starting-words semantic section, llms.txt update - Semantic: max guesses 15→20, compass/input/leaderboard polish - Speed: results display improvements - Profile: simplified install CTA, streak calendar fixes - Game modes: label updates, type additions
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
Gameplay: - Semantic maxGuesses increased from 15 to 20 - Single source of truth: GAME_MODE_CONFIG.semantic.maxGuesses Server and client both import from utils/game-modes.ts SEO: - Meta title: "Semantic Word Game — Like Contexto | Wordle English" - Meta description mentions meaning, compass, 275K words, Contexto/Semantle - 6 FAQ items (including "vs Contexto/Semantle"), 6 HowTo steps, tips - Speed mode: title, description, FAQ, HowTo, tips - Multiboard modes: descriptions for all 5 sizes, FAQ - llms.txt: expanded Semantic Explorer description with competitor comparison Fixes: - All "15 guesses" → "20 guesses" in config, descriptions, comments - Compass hint FAQ: "after every guess" (was "after 5 guesses") - Valid words: "275,000+" (was "50,000")
Summary
Major server architecture overhaul: migrate from disk-based caches + in-memory embeddings to Postgres, add leaderboard, fix security issues, and improve word explorer performance.
Database Migration
Memory and Startup
Code Cleanup
Word Explorer Performance
Security (full audit)
Cost Control
Leaderboard
SEO and Content
Test Plan