From fa68916d3e66060b28f77e8b5526dc15a7633a08 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Wed, 24 Sep 2025 05:48:06 -0500 Subject: [PATCH] Add SQL helper utilities and migrate queries --- AGENTS.md | 1 + script.js | 353 +++++++++++++++++++++++++++++++++--------------------- 2 files changed, 218 insertions(+), 136 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9a29ce..ed3b3dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ CREATE INDEX idx_artists_name_nocase ON artists(name COLLATE NOCASE); - `createAsyncListState` – Manages loading, error, and empty states for async lists. - `prepareForView` – Wraps `db.prepare` to attach view/label metadata for logging. - `normalizePaginationState` / `clampPaginationToTotal` – Normalizes and clamps pagination inputs while emitting DEBUG telemetry. +- `sqlValue`, `sqlLike`, `sqlIdentifier`, and the `sql` template tag – central helpers for manual SQL construction; always use these when interpolating dynamic values. ## Debugging & Logging - Runtime diagnostics are gated behind a localStorage flag. Enable with `localStorage.setItem('mp3com.debug', 'true')` (or `'false'`/`removeItem` to disable) before reloading the app. diff --git a/script.js b/script.js index 05ffae4..1cf3c3b 100644 --- a/script.js +++ b/script.js @@ -124,6 +124,89 @@ }); } + const SQL_FRAGMENT = Symbol('sqlFragment'); + + function createSqlFragment(text, raw) { + return { + text, + raw, + [SQL_FRAGMENT]: true, + }; + } + + function isSqlFragment(value) { + return Boolean(value && value[SQL_FRAGMENT]); + } + + function quoteSqlString(value) { + return `'${String(value).replace(/'/g, "''")}'`; + } + + function sqlValue(value) { + if (value === null || value === undefined) { + return createSqlFragment('NULL', value); + } + const type = typeof value; + if (type === 'number') { + if (!Number.isFinite(value)) throw new Error(`Invalid numeric SQL value: ${value}`); + return createSqlFragment(String(value), value); + } + if (type === 'boolean') { + return createSqlFragment(value ? '1' : '0', value); + } + if (type === 'string') { + return createSqlFragment(quoteSqlString(value), value); + } + throw new Error(`Unsupported SQL value type: ${type}`); + } + + function sqlLike(value, { escapeChar = '\\' } = {}) { + if (escapeChar === null || escapeChar === undefined) throw new Error('escapeChar required'); + if (typeof escapeChar !== 'string' || escapeChar.length !== 1) throw new Error('escapeChar must be a single character'); + if (value === null || value === undefined) { + return createSqlFragment('NULL', { value, escapeChar }); + } + const raw = String(value); + const regex = new RegExp(`[${escapeChar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}%_]`, 'g'); + const escaped = raw.replace(regex, (match) => escapeChar + match); + return createSqlFragment(quoteSqlString(escaped), { value, escapeChar }); + } + + function sqlIdentifier(name, { allowlist } = {}) { + if (typeof name !== 'string' || !name) throw new Error('Identifier must be a non-empty string'); + if (!Array.isArray(allowlist) || allowlist.length === 0) throw new Error('allowlist required for sqlIdentifier'); + if (!allowlist.includes(name)) { + throw new Error(`Identifier not in allowlist: ${name}`); + } + return createSqlFragment(name, name); + } + + function buildSql(strings, ...values) { + if (!Array.isArray(strings) || strings.length === 0) throw new Error('sql tag requires template literals'); + let sqlText = ''; + const params = []; + for (let i = 0; i < strings.length; i += 1) { + sqlText += strings[i]; + if (i >= values.length) continue; + const part = values[i]; + if (isSqlFragment(part)) { + sqlText += part.text; + params.push(part.raw); + continue; + } + if (part && typeof part === 'object' && typeof part.sql === 'string') { + sqlText += part.sql; + if (Array.isArray(part.params)) params.push(...part.params); + else if (part.params !== undefined) params.push(part.params); + continue; + } + throw new Error('sql template values must be created with sqlValue/sqlLike/sqlIdentifier or other sql fragments'); + } + return { sql: sqlText, params }; + } + + const sql = buildSql; + function logPaginationState({ view, rawPage, rawPageSize, page, pageSize, offset, flags }) { logDebug('[Pagination]', { view: view || 'unknown', @@ -136,6 +219,20 @@ }); } + function normalizeSqlInput(sqlInput) { + if (typeof sqlInput === 'string') { + return { sql: sqlInput, params: undefined }; + } + if (sqlInput && typeof sqlInput === 'object') { + const sqlText = sqlInput.sql; + if (typeof sqlText !== 'string' || !sqlText) { + throw new Error('Invalid SQL input: missing sql string'); + } + return { sql: sqlText, params: cloneBoundParams(sqlInput.params) }; + } + throw new Error('Invalid SQL input'); + } + function normalizePagination({ page, pageSize, defaultPageSize, view }) { const safeDefault = Number.isFinite(defaultPageSize) && defaultPageSize > 0 ? Math.max(1, Math.floor(defaultPageSize)) : 25; const rawPage = page; @@ -492,16 +589,6 @@ return `${hz.toLocaleString()} Hz`; } - function escapeSqlText(value) { - return `'${String(value).replace(/'/g, "''")}'`; - } - - function applyFtsMatch(sql, matchExpr) { - const placeholder = 'MATCH ?'; - if (!sql.includes(placeholder)) throw new Error('Expected FTS MATCH placeholder'); - return sql.replace(placeholder, `MATCH ${escapeSqlText(matchExpr)}`); - } - async function unzipSqlite(zipBytes) { loader.setStep('Unpacking database…'); loader.setDetail('Decompressing ZIP'); @@ -538,7 +625,7 @@ view: meta.view || 'unknown', label: meta.label || undefined, }; - this._boundParams = undefined; + this._paramsForLog = cloneBoundParams(meta.params); this._rowCount = 0; this._startedAt = 0; this._errored = false; @@ -548,17 +635,10 @@ if (meta.sql) this._meta.sql = meta.sql; if (meta.view) this._meta.view = meta.view; if (Object.prototype.hasOwnProperty.call(meta, 'label')) this._meta.label = meta.label; - return this; - } - bind(values) { - try { - if (values !== undefined) this._stmt.bind(values); - this._boundParams = cloneBoundParams(values); - return true; - } catch (error) { - this._recordError(error, 'bind'); - throw error; + if (Object.prototype.hasOwnProperty.call(meta, 'params')) { + this._paramsForLog = cloneBoundParams(meta.params); } + return this; } step() { try { @@ -602,7 +682,7 @@ logQueryBegin({ view: this._meta.view, sql: this._meta.sql, - params: this._boundParams, + params: this._paramsForLog, label: this._meta.label, }); } @@ -612,7 +692,7 @@ logQueryEnd({ view: this._meta.view, sql: this._meta.sql, - params: this._boundParams, + params: this._paramsForLog, rowCount: this._rowCount, durationMs: duration, label: this._meta.label, @@ -627,7 +707,7 @@ logSqlError({ view: this._meta.view, sql: this._meta.sql, - params: this._boundParams, + params: this._paramsForLog, error, phase, }); @@ -639,9 +719,10 @@ const source = bytes instanceof Uint8Array ? bytes : bytes ? new Uint8Array(bytes) : undefined; this._db = source ? new SQL.Database(source) : new SQL.Database(); } - prepare(sql, context) { - const meta = context ? { ...context, sql } : { sql }; - return new Statement(this._db.prepare(sql), meta); + prepare(sqlInput, context) { + const { sql: sqlText, params } = normalizeSqlInput(sqlInput); + const meta = context ? { ...context, sql: sqlText, params } : { sql: sqlText, params }; + return new Statement(this._db.prepare(sqlText), meta); } exec(sql) { return this._db.exec(sql); @@ -1179,34 +1260,38 @@ } const searchSql = { - rank: ` - SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre - FROM fts_tracks - JOIN tracks t ON t.id = fts_tracks.rowid - JOIN artists a ON a.id = t.artist_id - LEFT JOIN albums al ON al.id = t.album_id - WHERE fts_tracks MATCH ? - ORDER BY rank - LIMIT ? OFFSET ? - `, - alpha: ` - SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre - FROM fts_tracks - JOIN tracks t ON t.id = fts_tracks.rowid - JOIN artists a ON a.id = t.artist_id - LEFT JOIN albums al ON al.id = t.album_id - WHERE fts_tracks MATCH ? - ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0) - LIMIT ? OFFSET ? - `, + rank({ matchExpr, pageSize, offset }) { + return sql` + SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre + FROM fts_tracks + JOIN tracks t ON t.id = fts_tracks.rowid + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE fts_tracks MATCH ${sqlValue(matchExpr)} + ORDER BY rank + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} + `; + }, + alpha({ matchExpr, pageSize, offset }) { + return sql` + SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre + FROM fts_tracks + JOIN tracks t ON t.id = fts_tracks.rowid + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE fts_tracks MATCH ${sqlValue(matchExpr)} + ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0) + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} + `; + }, }; - const countSql = ` + const countSql = ({ matchExpr }) => sql` SELECT COUNT(*) AS count FROM fts_tracks JOIN tracks t ON t.id = fts_tracks.rowid JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id - WHERE fts_tracks MATCH ? + WHERE fts_tracks MATCH ${sqlValue(matchExpr)} `; function enableSearch() { @@ -1252,20 +1337,17 @@ defaultPageSize, view: VIEW_NAMES.search, }); - const effectiveSearchSql = searchSql[state.sort] || searchSql.rank; + const effectiveSearchBuilder = searchSql[state.sort] || searchSql.rank; let total = 0; let rows = []; try { + const countQuery = countSql({ matchExpr }); logDebug('[Search] count SQL', { matchExpr, - sql: countSql.trim(), + sql: formatSql(countQuery.sql), + params: formatParamsForLog(countQuery.params), }); - const countStmt = prepareForView( - db, - VIEW_NAMES.search, - applyFtsMatch(countSql, matchExpr), - 'count', - ); + const countStmt = prepareForView(db, VIEW_NAMES.search, countQuery, 'count'); if (countStmt.step()) { const row = countStmt.getAsObject(); total = Number(row.count) || 0; @@ -1290,20 +1372,13 @@ view: VIEW_NAMES.search, })); } - - const searchParams = [pageSize, offset]; + const rowsQuery = effectiveSearchBuilder({ matchExpr, pageSize, offset }); logDebug('[Search] row SQL', { matchExpr, - sql: effectiveSearchSql.trim(), - params: searchParams, + sql: formatSql(rowsQuery.sql), + params: formatParamsForLog(rowsQuery.params), }); - const searchStmt = prepareForView( - db, - VIEW_NAMES.search, - applyFtsMatch(effectiveSearchSql, matchExpr), - 'rows', - ); - searchStmt.bind(searchParams); + const searchStmt = prepareForView(db, VIEW_NAMES.search, rowsQuery, 'rows'); const nextRows = []; while (searchStmt.step()) { nextRows.push(searchStmt.getAsObject()); @@ -1445,31 +1520,34 @@ }); } - function escapeLike(str) { - return String(str).replace(/[\\%_]/g, (m) => `\\${m}`); - } - function buildLikeTerm() { const typed = state.filter.trim(); - if (typed) return `%${escapeLike(typed)}%`; - if (state.prefix) return `${escapeLike(state.prefix)}%`; + if (typed) return `%${typed}%`; + if (state.prefix) return `${state.prefix}%`; return '%'; } - const baseCountSql = 'SELECT COUNT(*) AS count FROM artists'; - const buildBaseRowsSql = (pageSize, offset) => ` - SELECT id, name + const baseCountSql = sql` + SELECT COUNT(*) AS count FROM artists - ORDER BY name COLLATE NOCASE - LIMIT ${pageSize} OFFSET ${offset} `; - const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"'; - const buildRowsSql = (likeValue, pageSize, offset) => ` + const buildBaseRowsSql = (pageSize, offset) => sql` SELECT id, name FROM artists - WHERE name LIKE ${escapeSqlText(likeValue)} ESCAPE "\\" ORDER BY name COLLATE NOCASE - LIMIT ${pageSize} OFFSET ${offset} + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} + `; + const buildFilteredCountSql = (likeValue) => sql` + SELECT COUNT(*) AS count + FROM artists + WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\' + `; + const buildFilteredRowsSql = (likeValue, pageSize, offset) => sql` + SELECT id, name + FROM artists + WHERE name LIKE ${sqlLike(likeValue)} ESCAPE '\\' + ORDER BY name COLLATE NOCASE + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; function updateJumpButtons() { jumpButtons.forEach((btn) => { @@ -1510,13 +1588,13 @@ const rows = []; try { + const countQuery = useUnfilteredQuery ? baseCountSql : buildFilteredCountSql(likeTerm); const countStmt = prepareForView( db, VIEW_NAMES.browseArtists, - useUnfilteredQuery ? baseCountSql : countSql, + countQuery, useUnfilteredQuery ? 'count' : 'count-filtered', ); - if (!useUnfilteredQuery) countStmt.bind([likeTerm]); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); @@ -1538,12 +1616,13 @@ ({ pageSize, offset } = sanitized); } + const rowsQuery = useUnfilteredQuery + ? buildBaseRowsSql(pageSize, offset) + : buildFilteredRowsSql(likeTerm, pageSize, offset); const rowsStmt = prepareForView( db, VIEW_NAMES.browseArtists, - useUnfilteredQuery - ? buildBaseRowsSql(pageSize, offset) - : buildRowsSql(likeTerm, pageSize, offset), + rowsQuery, useUnfilteredQuery ? 'rows' : 'rows-filtered', ); while (rowsStmt.step()) rows.push(rowsStmt.getAsObject()); @@ -1666,13 +1745,17 @@ title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)', }; - const countSql = 'SELECT COUNT(*) AS count FROM albums'; - const baseSql = ` + const countSql = sql` + SELECT COUNT(*) AS count + FROM albums + `; + + const buildRowsSql = (orderExpr, pageSize, offset) => sql` SELECT al.id, al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id = al.artist_id - ORDER BY %ORDER% - LIMIT ? OFFSET ? + ORDER BY ${sqlIdentifier(orderExpr, { allowlist: Object.values(orderMap) })} + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; function loadAlbumsImmediate() { @@ -1710,13 +1793,13 @@ } const order = orderMap[state.sort] || orderMap.artist; + const rowsQuery = buildRowsSql(order, pageSize, offset); const stmt = prepareForView( db, VIEW_NAMES.browseAlbums, - baseSql.replace('%ORDER%', order), + rowsQuery, 'rows', ); - stmt.bind([pageSize, offset]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); } catch (err) { @@ -1820,22 +1903,26 @@ let yearButtons = []; - const yearsSql = ` + const yearsSql = sql` SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY year `; - const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE year = ?'; - const tracksRowsSql = ` + const tracksCountSql = (year) => sql` + SELECT COUNT(*) AS count + FROM tracks + WHERE year = ${sqlValue(year)} + `; + const tracksRowsSql = (year, pageSize, offset) => sql` SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre FROM tracks t JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id - WHERE t.year = ? + WHERE t.year = ${sqlValue(year)} ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE - LIMIT ? OFFSET ? + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; function updateYearButtons() { @@ -1919,10 +2006,9 @@ const countStmt = prepareForView( db, VIEW_NAMES.browseYears, - tracksCountSql, + tracksCountSql(state.selectedYear), 'tracks-count', ); - countStmt.bind([state.selectedYear]); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); @@ -1948,10 +2034,9 @@ const stmt = prepareForView( db, VIEW_NAMES.browseYears, - tracksRowsSql, + tracksRowsSql(state.selectedYear, pageSize, offset), 'tracks-rows', ); - stmt.bind([state.selectedYear, pageSize, offset]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); } catch (err) { @@ -2051,22 +2136,26 @@ let genreButtons = []; - const genresSql = ` + const genresSql = sql` SELECT genre, COUNT(*) AS cnt FROM tracks WHERE genre IS NOT NULL AND genre != '' GROUP BY genre ORDER BY cnt DESC, genre COLLATE NOCASE `; - const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE genre = ?'; - const tracksRowsSql = ` + const tracksCountSql = (genre) => sql` + SELECT COUNT(*) AS count + FROM tracks + WHERE genre = ${sqlValue(genre)} + `; + const tracksRowsSql = (genre, pageSize, offset) => sql` SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year FROM tracks t JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id - WHERE t.genre = ? + WHERE t.genre = ${sqlValue(genre)} ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE - LIMIT ? OFFSET ? + LIMIT ${sqlValue(pageSize)} OFFSET ${sqlValue(offset)} `; function renderGenres() { @@ -2145,10 +2234,9 @@ const countStmt = prepareForView( db, VIEW_NAMES.browseGenres, - tracksCountSql, + tracksCountSql(state.selectedGenre), 'tracks-count', ); - countStmt.bind([state.selectedGenre]); if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; countStmt.free(); @@ -2174,10 +2262,9 @@ const stmt = prepareForView( db, VIEW_NAMES.browseGenres, - tracksRowsSql, + tracksRowsSql(state.selectedGenre, pageSize, offset), 'tracks-rows', ); - stmt.bind([state.selectedGenre, pageSize, offset]); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); } catch (err) { @@ -2507,23 +2594,23 @@ let artistInfo = null; - const headerSql = ` + const headerSql = (id) => sql` SELECT a.name, (SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count, (SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count FROM artists a - WHERE a.id = ? + WHERE a.id = ${sqlValue(id)} `; - const albumsSql = ` + const albumsSql = (id) => sql` SELECT id, title, year FROM albums - WHERE artist_id = ? + WHERE artist_id = ${sqlValue(id)} ORDER BY COALESCE(year, 0), title COLLATE NOCASE `; - const tracksSql = ` + const tracksSql = (id) => sql` SELECT id, title, year, genre FROM tracks - WHERE artist_id = ? + WHERE artist_id = ${sqlValue(id)} ORDER BY COALESCE(year, 0), title COLLATE NOCASE LIMIT 100 `; @@ -2567,8 +2654,7 @@ function loadHeader() { try { - const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql, 'artist-header'); - stmt.bind([artistId]); + const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, headerSql(artistId), 'artist-header'); if (stmt.step()) { artistInfo = stmt.getAsObject(); $name.textContent = artistInfo.name; @@ -2591,8 +2677,7 @@ loaded.add('albums'); try { const rows = []; - const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql, 'artist-albums'); - stmt.bind([artistId]); + const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, albumsSql(artistId), 'artist-albums'); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); albumTable.setRows(rows); @@ -2609,8 +2694,7 @@ loaded.add('tracks'); try { const rows = []; - const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql, 'artist-tracks'); - stmt.bind([artistId]); + const stmt = prepareForView(db, VIEW_NAMES.artistOverlay, tracksSql(artistId), 'artist-tracks'); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); trackTable.setRows(rows); @@ -2684,16 +2768,16 @@ const $close = el.querySelector('[data-action="close"]'); const $tracks = el.querySelector('[data-ref="tracks"]'); - const headerSql = ` + const headerSql = (id) => sql` SELECT al.title, al.year, a.name AS artist, a.id AS artist_id FROM albums al JOIN artists a ON a.id = al.artist_id - WHERE al.id = ? + WHERE al.id = ${sqlValue(id)} `; - const tracksSql = ` + const tracksSql = (id) => sql` SELECT id, track_no, title, duration_sec, bitrate_kbps FROM tracks - WHERE album_id = ? + WHERE album_id = ${sqlValue(id)} ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE `; @@ -2720,8 +2804,7 @@ function loadHeader() { try { - const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql, 'album-header'); - stmt.bind([albumId]); + const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, headerSql(albumId), 'album-header'); if (stmt.step()) { const info = stmt.getAsObject(); artistId = Number(info.artist_id); @@ -2744,8 +2827,7 @@ function loadTracks() { try { const rows = []; - const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql, 'album-tracks'); - stmt.bind([albumId]); + const stmt = prepareForView(db, VIEW_NAMES.albumOverlay, tracksSql(albumId), 'album-tracks'); while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); trackTable.setRows(rows); @@ -2816,7 +2898,7 @@ const $close = el.querySelector('[data-action="close"]'); const $copy = el.querySelector('[data-action="copy-path"]'); - const sql = ` + const trackSql = (id) => sql` SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz, t.channels, t.filesize_bytes, t.sha1, t.relpath, a.id AS artist_id, a.name AS artist, @@ -2824,15 +2906,14 @@ FROM tracks t JOIN artists a ON a.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id - WHERE t.id = ? + WHERE t.id = ${sqlValue(id)} `; let track = null; function loadTrack() { try { - const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, sql, 'track-detail'); - stmt.bind([trackId]); + const stmt = prepareForView(db, VIEW_NAMES.trackOverlay, trackSql(trackId), 'track-detail'); if (stmt.step()) { track = stmt.getAsObject(); $title.textContent = track.title || 'Untitled track';