diff --git a/src/controllers/search.js b/src/controllers/search.js index ddad03e7..200146e5 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -1,6 +1,7 @@ import lodash from 'lodash'; import { SourceNotFoundError } from '../errors/index.js'; import toListResponse from '../utils/toListResponse.js'; +import { json, jsonb } from '../utils/sqlite.js'; const { isEqual } = lodash; @@ -47,7 +48,7 @@ function updateSourceData(uw, updates) { for (const [id, sourceData] of updates.entries()) { await tx.updateTable('media') .where('id', '=', id) - .set({ sourceData }) + .set({ sourceData: sourceData == null ? null : jsonb(sourceData) }) .executeTakeFirst(); } }); @@ -91,7 +92,12 @@ async function search(req) { const mediasNeedSourceDataUpdate = new Map(); const mediasInSearchResults = await db.selectFrom('media') - .select(['id', 'sourceType', 'sourceID', 'sourceData']) + .select([ + 'id', + 'sourceType', + 'sourceID', + (eb) => json(eb.fn.coalesce(eb.ref('sourceData'), jsonb(null))).as('sourceData'), + ]) .where('sourceType', '=', sourceName) .where('sourceID', 'in', Array.from(searchResultsByID.keys())) .execute(); diff --git a/src/plugins/acl.js b/src/plugins/acl.js index cd71e0c3..7b540c9c 100644 --- a/src/plugins/acl.js +++ b/src/plugins/acl.js @@ -1,7 +1,12 @@ -import { sql } from 'kysely'; import defaultRoles from '../config/defaultRoles.js'; import routes from '../routes/acl.js'; -import { isForeignKeyError, jsonb, jsonEach } from '../utils/sqlite.js'; +import { + isForeignKeyError, + fromJson, + json, + jsonb, + jsonEach, +} from '../utils/sqlite.js'; import { RoleNotFoundError } from '../errors/index.js'; /** @@ -75,14 +80,13 @@ class Acl { * @returns {Promise>} */ async getAllRoles(tx = this.#uw.db) { - // TODO: `json()` should be strongly typed const list = await tx.selectFrom('roles') - .select(['id', sql`json(permissions)`.as('permissions')]) + .select(['id', (eb) => json(eb.ref('permissions')).as('permissions')]) .execute(); const roles = Object.fromEntries(list.map((role) => [ role.id, - /** @type {Permission[]} */ (JSON.parse(/** @type {string} */ (role.permissions))), + fromJson(role.permissions), ])); return roles; diff --git a/src/plugins/booth.js b/src/plugins/booth.js index a73055d4..004e2919 100644 --- a/src/plugins/booth.js +++ b/src/plugins/booth.js @@ -2,7 +2,7 @@ import RedLock from 'redlock'; import { EmptyPlaylistError, PlaylistItemNotFoundError } from '../errors/index.js'; import routes from '../routes/booth.js'; import { randomUUID } from 'node:crypto'; -import { jsonb } from '../utils/sqlite.js'; +import { fromJson, jsonb, jsonGroupArray } from '../utils/sqlite.js'; /** * @typedef {import('../schema.js').UserID} UserID @@ -136,17 +136,17 @@ class Booth { (eb) => eb.selectFrom('feedback') .where('historyEntryID', '=', eb.ref('historyEntries.id')) .where('vote', '=', 1) - .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .select((eb) => jsonGroupArray(eb.ref('userID')).as('userIDs')) .as('upvotes'), (eb) => eb.selectFrom('feedback') .where('historyEntryID', '=', eb.ref('historyEntries.id')) .where('vote', '=', -1) - .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .select((eb) => jsonGroupArray(eb.ref('userID')).as('userIDs')) .as('downvotes'), (eb) => eb.selectFrom('feedback') .where('historyEntryID', '=', eb.ref('historyEntries.id')) .where('favorite', '=', 1) - .select((eb) => eb.fn.agg('json_group_array', ['userID']).as('userIDs')) + .select((eb) => jsonGroupArray(eb.ref('userID')).as('userIDs')) .as('favorites'), ]) .where('historyEntries.id', '=', historyID) @@ -179,9 +179,9 @@ class Booth { end: entry.end, createdAt: entry.createdAt, }, - upvotes: /** @type {UserID[]} */ (JSON.parse(entry.upvotes)), - downvotes: /** @type {UserID[]} */ (JSON.parse(entry.downvotes)), - favorites: /** @type {UserID[]} */ (JSON.parse(entry.favorites)), + upvotes: entry.upvotes != null ? fromJson(entry.upvotes) : [], + downvotes: entry.downvotes != null ? fromJson(entry.downvotes) : [], + favorites: entry.favorites != null ? fromJson(entry.favorites) : [], } : null; } @@ -236,6 +236,7 @@ class Booth { end: playlistItem.end, /** @type {null | JsonObject} */ sourceData: null, + createdAt: new Date(), }, }; } @@ -435,11 +436,12 @@ class Booth { title: next.playlistItem.title, }, 'next track'); const sourceData = await this.#getSourceDataForPlayback(next); - if (sourceData) { - next.historyEntry.sourceData = sourceData; - } - const historyEntry = await tx.insertInto('historyEntries') - .returningAll() + + // Conservatively, we should take *all* the data from the inserted values. + // But then we need to reparse the source data... It's easier to only take + // the actually generated value from there :') + const { createdAt } = await tx.insertInto('historyEntries') + .returning('createdAt') .values({ id: next.historyEntry.id, userID: next.user.id, @@ -452,8 +454,13 @@ class Booth { }) .executeTakeFirstOrThrow(); + if (sourceData != null) { + next.historyEntry.sourceData = sourceData; + } + next.historyEntry.createdAt = createdAt; + result = { - historyEntry, + historyEntry: next.historyEntry, playlist: next.playlist, user: next.user, media: next.media, diff --git a/src/plugins/configStore.js b/src/plugins/configStore.js index 125e2fba..180f2748 100644 --- a/src/plugins/configStore.js +++ b/src/plugins/configStore.js @@ -6,7 +6,7 @@ import jsonMergePatch from 'json-merge-patch'; import sjson from 'secure-json-parse'; import ValidationError from '../errors/ValidationError.js'; import { sql } from 'kysely'; -import { jsonb } from '../utils/sqlite.js'; +import { fromJson, json, jsonb } from '../utils/sqlite.js'; /** * @typedef {import('type-fest').JsonObject} JsonObject @@ -114,7 +114,7 @@ class ConfigStore { const previous = await db.transaction().execute(async (tx) => { const row = await tx.selectFrom('configuration') - .select(sql`json(value)`.as('value')) + .select((eb) => json(eb.ref('value')).as('value')) .where('name', '=', name) .executeTakeFirst(); @@ -123,7 +123,7 @@ class ConfigStore { .onConflict((oc) => oc.column('name').doUpdateSet({ value: jsonb(value) })) .execute(); - return row?.value != null ? JSON.parse(/** @type {string} */ (row.value)) : null; + return row?.value != null ? fromJson(row.value) : null; }); return previous; @@ -137,14 +137,14 @@ class ConfigStore { const { db } = this.#uw; const row = await db.selectFrom('configuration') - .select(sql`json(value)`.as('value')) + .select((eb) => json(eb.ref('value')).as('value')) .where('name', '=', key) .executeTakeFirst(); - if (!row) { + if (row == null || row.value == null) { return null; } - return JSON.parse(/** @type {string} */ (row.value)); + return fromJson(row.value); } /** diff --git a/src/plugins/history.js b/src/plugins/history.js index 7889396b..efb4ceee 100644 --- a/src/plugins/history.js +++ b/src/plugins/history.js @@ -1,5 +1,6 @@ import lodash from 'lodash'; import Page from '../Page.js'; +import { fromJson, json, jsonb } from '../utils/sqlite.js'; const { clamp } = lodash; @@ -14,7 +15,8 @@ const historyEntrySelection = /** @type {const} */ ([ 'historyEntries.title', 'historyEntries.start', 'historyEntries.end', - 'historyEntries.sourceData', + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => json(eb.fn.coalesce(eb.ref('historyEntries.sourceData'), jsonb(null))).as('sourceData'), 'historyEntries.createdAt as playedAt', 'users.id as user.id', 'users.username as user.username', @@ -27,7 +29,8 @@ const historyEntrySelection = /** @type {const} */ ([ 'media.duration as media.duration', 'media.sourceType as media.sourceType', 'media.sourceID as media.sourceID', - 'media.sourceData as media.sourceData', + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => json(eb.fn.coalesce(eb.ref('media.sourceData'), jsonb(null))).as('media.sourceData'), /** @param {import('kysely').ExpressionBuilder} eb */ (eb) => eb.selectFrom('feedback') .where('historyEntryID', '=', eb.ref('historyEntries.id')) @@ -55,7 +58,7 @@ const historyEntrySelection = /** @type {const} */ ([ * title: string, * start: number, * end: number, - * sourceData: import('type-fest').JsonObject | null, + * sourceData: import('../utils/sqlite.js').SerializedJSON, * playedAt: Date, * 'user.id': UserID, * 'user.username': string, @@ -64,14 +67,15 @@ const historyEntrySelection = /** @type {const} */ ([ * 'media.id': MediaID, * 'media.sourceType': string, * 'media.sourceID': string, - * 'media.sourceData': import('type-fest').JsonObject | null, + * 'media.sourceData': import('../utils/sqlite.js').SerializedJSON< + * import('type-fest').JsonObject | null>, * 'media.artist': string, * 'media.title': string, * 'media.thumbnail': string, * 'media.duration': number, - * upvotes: string, - * downvotes: string, - * favorites: string, + * upvotes: import('../utils/sqlite.js').SerializedJSON, + * downvotes: import('../utils/sqlite.js').SerializedJSON, + * favorites: import('../utils/sqlite.js').SerializedJSON, * }} row */ function historyEntryFromRow(row) { @@ -101,12 +105,9 @@ function historyEntryFromRow(row) { duration: row['media.duration'], }, }, - /** @type {UserID[]} */ - upvotes: JSON.parse(row.upvotes), - /** @type {UserID[]} */ - downvotes: JSON.parse(row.downvotes), - /** @type {UserID[]} */ - favorites: JSON.parse(row.favorites), + upvotes: fromJson(row.upvotes), + downvotes: fromJson(row.downvotes), + favorites: fromJson(row.favorites), }; } diff --git a/src/plugins/playlists.js b/src/plugins/playlists.js index a7639396..4301d8e1 100644 --- a/src/plugins/playlists.js +++ b/src/plugins/playlists.js @@ -10,7 +10,13 @@ import routes from '../routes/playlists.js'; import { randomUUID } from 'node:crypto'; import { sql } from 'kysely'; import { - arrayCycle, jsonb, jsonEach, jsonLength, arrayShuffle as arrayShuffle, + arrayCycle, + arrayShuffle as arrayShuffle, + fromJson, + json, + jsonb, + jsonEach, + jsonLength, } from '../utils/sqlite.js'; import Multimap from '../utils/Multimap.js'; @@ -23,6 +29,7 @@ import Multimap from '../utils/Multimap.js'; * @typedef {import('../schema.js').Playlist} Playlist * @typedef {import('../schema.js').PlaylistItem} PlaylistItem * @typedef {import('../schema.js').Media} Media + * @typedef {import('../schema.js').Database} Database */ /** @@ -39,7 +46,7 @@ import Multimap from '../utils/Multimap.js'; * Calculate valid start/end times for a playlist item. * * @param {PlaylistItemDesc} item - * @param {Media} media + * @param {Pick} media */ function getStartEnd(item, media) { let { start, end } = item; @@ -61,7 +68,8 @@ const playlistItemSelection = /** @type {const} */ ([ 'media.id as media.id', 'media.sourceID as media.sourceID', 'media.sourceType as media.sourceType', - 'media.sourceData as media.sourceData', + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => json(eb.fn.coalesce(eb.ref('media.sourceData'), jsonb(null))).as('media.sourceData'), 'media.artist as media.artist', 'media.title as media.title', 'media.duration as media.duration', @@ -74,13 +82,26 @@ const playlistItemSelection = /** @type {const} */ ([ 'playlistItems.updatedAt', ]); +const mediaSelection = /** @type {const} */ ([ + 'id', + 'sourceType', + 'sourceID', + /** @param {import('kysely').ExpressionBuilder} eb */ + (eb) => json(eb.fn.coalesce(eb.ref('sourceData'), jsonb(null))).as('sourceData'), + 'artist', + 'title', + 'duration', + 'thumbnail', +]); + /** * @param {{ * id: PlaylistItemID, * 'media.id': MediaID, * 'media.sourceID': string, * 'media.sourceType': string, - * 'media.sourceData': import('type-fest').JsonObject | null, + * 'media.sourceData': import('../utils/sqlite.js').SerializedJSON< + * import('type-fest').JsonObject | null>, * 'media.artist': string, * 'media.title': string, * 'media.duration': number, @@ -106,7 +127,7 @@ function playlistItemFromSelection(raw) { thumbnail: raw['media.thumbnail'], sourceID: raw['media.sourceID'], sourceType: raw['media.sourceType'], - sourceData: raw['media.sourceData'], + sourceData: fromJson(raw['media.sourceData']), }, }; } @@ -117,7 +138,8 @@ function playlistItemFromSelection(raw) { * 'media.id': MediaID, * 'media.sourceID': string, * 'media.sourceType': string, - * 'media.sourceData': import('type-fest').JsonObject | null, + * 'media.sourceData': import('../utils/sqlite.js').SerializedJSON< + * import('type-fest').JsonObject | null>, * 'media.artist': string, * 'media.title': string, * 'media.duration': number, @@ -150,11 +172,36 @@ function playlistItemFromSelectionNew(raw) { thumbnail: raw['media.thumbnail'], sourceID: raw['media.sourceID'], sourceType: raw['media.sourceType'], - sourceData: raw['media.sourceData'], + sourceData: fromJson(raw['media.sourceData']), }, }; } +/** + * @param {{ + * id: MediaID, + * sourceID: string, + * sourceType: string, + * sourceData: import('../utils/sqlite.js').SerializedJSON, + * artist: string, + * title: string, + * duration: number, + * thumbnail: string, + * }} raw + */ +function mediaFromRow(raw) { + return { + id: raw.id, + sourceID: raw.sourceID, + sourceType: raw.sourceType, + sourceData: fromJson(raw.sourceData), + artist: raw.artist, + title: raw.title, + duration: raw.duration, + thumbnail: raw.thumbnail, + }; +} + class PlaylistsRepository { #uw; @@ -505,19 +552,19 @@ class PlaylistsRepository { // Group by source so we can retrieve all unknown medias from the source in // one call. const itemsBySourceType = ObjectGroupBy(items, (item) => item.sourceType); - /** @type {Map} */ + /** @type {Map>} */ const allMedias = new Map(); const promises = Object.entries(itemsBySourceType).map(async ([sourceType, sourceItems]) => { const knownMedias = await db.selectFrom('media') .where('sourceType', '=', sourceType) .where('sourceID', 'in', sourceItems.map((item) => String(item.sourceID))) - .selectAll() + .select(mediaSelection) .execute(); /** @type {Set} */ const knownMediaIDs = new Set(); knownMedias.forEach((knownMedia) => { - allMedias.set(`${knownMedia.sourceType}:${knownMedia.sourceID}`, knownMedia); + allMedias.set(`${knownMedia.sourceType}:${knownMedia.sourceID}`, mediaFromRow(knownMedia)); knownMediaIDs.add(knownMedia.sourceID); }); @@ -538,16 +585,16 @@ class PlaylistsRepository { id: /** @type {MediaID} */ (randomUUID()), sourceType: media.sourceType, sourceID: media.sourceID, - sourceData: jsonb(media.sourceData), + sourceData: media.sourceData == null ? null : jsonb(media.sourceData), artist: media.artist, title: media.title, duration: media.duration, thumbnail: media.thumbnail, }) - .returningAll() + .returning(mediaSelection) .executeTakeFirstOrThrow(); - allMedias.set(`${media.sourceType}:${media.sourceID}`, newMedia); + allMedias.set(`${media.sourceType}:${media.sourceID}`, mediaFromRow(newMedia)); } } }); @@ -611,12 +658,11 @@ class PlaylistsRepository { } const result = await tx.selectFrom('playlists') - .select(sql`json(items)`.as('items')) + .select((eb) => json(eb.ref('items')).as('items')) .where('id', '=', playlist.id) .executeTakeFirstOrThrow(); - /** @type {PlaylistItemID[]} */ - const oldItems = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : []; + const oldItems = result?.items ? fromJson(result.items) : []; /** @type {PlaylistItemID | null} */ let after; @@ -677,11 +723,11 @@ class PlaylistsRepository { await db.transaction().execute(async (tx) => { const result = await tx.selectFrom('playlists') - .select(sql`json(items)`.as('items')) + .select((eb) => json(eb.ref('items')).as('items')) .where('id', '=', playlist.id) .executeTakeFirst(); - const items = result?.items ? JSON.parse(/** @type {string} */ (result.items)) : []; + const items = result?.items ? fromJson(result.items) : []; const itemIDsInPlaylist = new Set(items); const itemIDsToMove = new Set(itemIDs.filter((itemID) => itemIDsInPlaylist.has(itemID))); diff --git a/src/plugins/users.js b/src/plugins/users.js index e00e17ef..f302ab79 100644 --- a/src/plugins/users.js +++ b/src/plugins/users.js @@ -7,7 +7,7 @@ import Page from '../Page.js'; import { IncorrectPasswordError, UsedEmailError, UsedUsernameError, UserNotFoundError, } from '../errors/index.js'; -import { jsonGroupArray } from '../utils/sqlite.js'; +import { fromJson, jsonGroupArray } from '../utils/sqlite.js'; const { pick, omit } = lodash; @@ -156,14 +156,10 @@ class UsersRepository { ]) .execute(); - for (const user of users) { - const roles = /** @type {string[]} */ (JSON.parse( - /** @type {string} */ (/** @type {unknown} */ (user.roles)), - )); - Object.assign(user, { roles }); - } - - return /** @type {import('type-fest').SetNonNullable<(typeof users)[0], 'roles'>[]} */ (users); + return users.map((user) => ({ + ...user, + roles: user.roles != null ? fromJson(user.roles) : [], + })); } /** diff --git a/src/schema.ts b/src/schema.ts index 53d80423..aa302b36 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,5 +1,6 @@ import type { Kysely as KyselyBase, Generated } from 'kysely'; -import type { JsonObject, Tagged } from 'type-fest'; // eslint-disable-line n/no-missing-import, n/no-unpublished-import +import type { JsonObject, JsonValue, Tagged } from 'type-fest'; // eslint-disable-line n/no-missing-import, n/no-unpublished-import +import type { JSONB } from './utils/sqlite'; export type UserID = Tagged; export type MediaID = Tagged; @@ -8,8 +9,18 @@ export type PlaylistItemID = Tagged; export type HistoryEntryID = Tagged; export type Permission = Tagged; +/** + * The JS type for a given table's rows. + * This combines two transformations: + * - Generated columns are resolved to their inner type. This is a type-level operation. + * - JSON columns are resolved to their JS type. This requires conversion code when querying the + * data. + */ type Selected = { - [K in keyof T]: T[K] extends Generated ? Inner : T[K]; + [K in keyof T]: T[K] extends Generated ? Inner + : T[K] extends JSONB ? T[K]['__inner'] + : T[K] extends JSONB | null ? (T[K] & {})['__inner'] | null + : T[K]; } & {}; export type Media = Selected; @@ -17,7 +28,7 @@ export interface MediaTable { id: Generated, sourceID: string, sourceType: string, - sourceData: JsonObject | null, + sourceData: JSONB | null, artist: string, title: string, duration: number, @@ -47,7 +58,7 @@ export interface UserRoleTable { export interface RoleTable { id: string, - permissions: Permission[], + permissions: JSONB, } export type Ban = Selected; @@ -85,7 +96,7 @@ export interface PlaylistTable { id: Generated, userID: UserID, name: string, - items: PlaylistItemID[], + items: JSONB, createdAt: Generated, updatedAt: Generated, } @@ -117,7 +128,7 @@ export interface HistoryEntryTable { /** Time to stop playback at. */ end: number, /** Arbitrary source-specific data required for media playback. */ - sourceData: JsonObject | null, + sourceData: JSONB | null, createdAt: Generated, } @@ -131,7 +142,7 @@ export interface FeedbackTable { export interface ConfigurationTable { name: string, - value: JsonObject | null, + value: JSONB, } export interface MigrationTable { diff --git a/src/utils/serialize.js b/src/utils/serialize.js index 07c3b1ad..13298ebe 100644 --- a/src/utils/serialize.js +++ b/src/utils/serialize.js @@ -40,7 +40,7 @@ export function serializeMedia(model) { /** * @param {{ * id: import('../schema.js').PlaylistItemID, - * media: import('../schema.js').Media, + * media: Parameters[0], * artist: string, * title: string, * start: number, diff --git a/src/utils/sqlite.js b/src/utils/sqlite.js index 22a7b11c..40d89e8e 100644 --- a/src/utils/sqlite.js +++ b/src/utils/sqlite.js @@ -2,8 +2,40 @@ import lodash from 'lodash'; import { sql, OperationNodeTransformer } from 'kysely'; /** + * Typed representation of encoded JSONB. You are not meant to actually instantiate + * a value of this type :) + * + * @template {import('type-fest').JsonValue} T + * @typedef {import('type-fest').Tagged & { __inner: T }} JSONB + */ + +/** + * Typed representation of an encoded JSON string. + * @template {import('type-fest').JsonValue} T + * @typedef {import('type-fest').Tagged & { __inner: T }} SerializedJSON + */ + +/** + * Any SQLite JSON value. + * + * @template {import('type-fest').JsonValue} T + * @typedef {JSONB | SerializedJSON} SqliteJSON + */ + +/** + * @template {import('type-fest').JsonValue} T + * @param {SerializedJSON} value + * @returns {T} + */ +export function fromJson(value) { + return JSON.parse(value); +} + +/** + * Note the `value` and `atom` types might be wrong for non-SQL JSON types + * * @template {unknown[]} T - * @param {import('kysely').Expression} expr + * @param {import('kysely').Expression>} expr * @returns {import('kysely').RawBuilder<{ * key: unknown, * value: T[0], @@ -21,7 +53,7 @@ export function jsonEach(expr) { /** * @template {unknown[]} T - * @param {import('kysely').Expression} expr + * @param {import('kysely').Expression>} expr * @returns {import('kysely').RawBuilder} */ export function jsonLength(expr) { @@ -29,17 +61,22 @@ export function jsonLength(expr) { } /** - * @param {import('type-fest').Jsonifiable} value - * @returns {import('kysely').RawBuilder} + * Turn a JS value into JSONB. + * + * @template {import('type-fest').JsonValue} T + * @param {T} value + * @returns {import('kysely').RawBuilder>} */ export function jsonb(value) { return sql`jsonb(${JSON.stringify(value)})`; } /** - * @template {unknown[]} T - * @param {import('kysely').Expression} expr - * @returns {import('kysely').RawBuilder} + * Turn a SQLite expression into a JSON string. + * + * @template {unknown} T + * @param {import('kysely').Expression>} expr + * @returns {import('kysely').RawBuilder>} */ export function json(expr) { return sql`json(${expr})`; @@ -47,17 +84,20 @@ export function json(expr) { /** * @template {unknown[]} T - * @param {import('kysely').Expression} expr - * @returns {import('kysely').RawBuilder} + * @param {import('kysely').Expression>} expr + * @returns {import('kysely').RawBuilder>} */ export function arrayShuffle(expr) { return sql`jsonb(json_array_shuffle(${json(expr)}))`; } /** + * Move the first item in an array to the end. + * This only works on JSONB inputs. + * * @template {unknown[]} T - * @param {import('kysely').Expression} expr - * @returns {import('kysely').RawBuilder} + * @param {import('kysely').Expression>} expr + * @returns {import('kysely').RawBuilder>} */ export function arrayCycle(expr) { return sql` @@ -75,7 +115,7 @@ export function arrayCycle(expr) { /** * @template {unknown} T * @param {import('kysely').Expression} expr - * @returns {import('kysely').RawBuilder} + * @returns {import('kysely').RawBuilder>} */ export function jsonGroupArray(expr) { return sql`json_group_array(${expr})`;