Skip to content

Commit 8765523

Browse files
Switch to a relational database (#637)
* It boots from SQL * progress on loading playlists * Use uppercase ID * Search based on SQL media * add userID to history entry schema * stash * Migrate history, read history * Typed IDs; move mostly to new schema types * Migrate authentication model to SQL * Update unique constraints * Fix lodash import * Select the right stuff from users * Use Object.groupBy * Use order column for playlist sorting? The other option is to have a JSON column with IDs on the playlists table. * Add linting for JSDoc * SQL config store * stash * Bump kysely * Different way to store playlist item order * Opaque -> Tagged * Port bans * deps: update better-sqlite3 * Remove mongodb connection code * Adding playlist items with sql? * Revert "Remove mongodb connection code" This reverts commit 8b2ae37. * Make migrations work in sql * Try with SQLite * Migrate auth passwords * Better Date support for SQLite * Use json_each * use json_array_length * SQLite utility functions * Fix property name in test * playlist shuffle and cycle with sqlite * Use a flat list of permissions * Various test fixes * Ban test sorta working * small test fixes * acl fixes * some more json sqlite fixes * serialize active playlist id * Implement playlist updates with sql * More JSON fun * users test fixes * test fixes for bans and /now * finish redis connection before changing configs * User avatar / roles return values * test ID fix * Fix playlist item serialization * implement removing playlist items * put comment * Fix issues due to playlist position options * disable sql query logging * various sql booth fixes * Test fixes by moving to new data structure * Inline the email function * Fix email test * This map is a multi map * fix playlist item filter * fix running into apparent bound param limit * Fix serializing media items * check passwords * various type fixes * Fix populating moderator in getBans * Produce JSON-compatible type in serializers * Miscellaneous type fixes * Port favouriting * Types in playlist advance * Update lint settings * Lint autofix * slight jank but its ok * Type fixes post merge * Only connect to mongo in the migration * Remove mongo from tests * Backwards compatibility for /api/now.roles * Implement votes * Move sqlite plugins etc into utils/sqlite * Lint migration * Fix vote queries in booth plugin * Mutes from redis to sqlite * Optionally run most functions in a transaction * deps: update node types * Record favorites in feedback table * remove jsdoc linting it gets in the way quite a bit. maybe later? * Use config store for MOTD * silence logs in tests * use pino-pretty * Fix vote test, do not re-emit unchanged vote submissions * Fix timezone confusion in bans * explicitly store UTC in sqlite * Run eslint --fix * Address lints * Disable lints that i cant get to work * ci: add node 22
1 parent 2a315bb commit 8765523

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+3261
-2012
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,12 @@ jobs:
3535
name: Tests
3636
strategy:
3737
matrix:
38-
node-version: ['18.x', '20.x']
39-
mongo-version: ['6.0', '7.0']
40-
include:
41-
- node-version: 'lts/*'
42-
mongo-version: '5.0'
38+
node-version: ['18.x', '20.x', '22.x']
4339
runs-on: ubuntu-latest
4440
services:
4541
redis:
4642
image: redis:6
4743
ports: ['6379:6379']
48-
mongodb:
49-
image: mongo:${{matrix.mongo-version}}
50-
ports: ['27017:27017']
5144
steps:
5245
- name: Checkout sources
5346
uses: actions/checkout@v4
@@ -63,7 +56,7 @@ jobs:
6356
REDIS_URL: '127.0.0.1:6379'
6457
MONGODB_HOST: '127.0.0.1:27017'
6558
- name: Submit coverage
66-
if: matrix.node-version == '20.x' && matrix.mongo-version == '7.0'
59+
if: matrix.node-version == '20.x'
6760
uses: coverallsapp/[email protected]
6861
with:
6962
github-token: ${{secrets.GITHUB_TOKEN}}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@ package-lock.json
3535
.nyc_output
3636

3737
.env
38+
39+
*.sqlite
40+
*.sqlite-shm
41+
*.sqlite-wal

package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"ajv-formats": "^3.0.1",
3333
"avvio": "^9.0.0",
3434
"bcryptjs": "^2.4.3",
35+
"better-sqlite3": "^11.2.1",
3536
"body-parser": "^1.19.0",
3637
"cookie": "^1.0.1",
3738
"cookie-parser": "^1.4.4",
@@ -47,15 +48,18 @@
4748
"ioredis": "^5.0.1",
4849
"json-merge-patch": "^1.0.2",
4950
"jsonwebtoken": "^9.0.0",
51+
"kysely": "^0.27.3",
5052
"lodash": "^4.17.15",
5153
"minimist": "^1.2.5",
5254
"mongoose": "^8.6.2",
5355
"ms": "^2.1.2",
5456
"node-fetch": "^3.3.1",
5557
"nodemailer": "^6.4.2",
58+
"object.groupby": "^1.0.1",
5659
"passport": "^0.5.0",
5760
"passport-google-oauth20": "^2.0.0",
5861
"passport-local": "^1.0.0",
62+
"pg": "^8.10.0",
5963
"pino": "^9.0.0",
6064
"pino-http": "^10.1.0",
6165
"qs": "^6.9.1",
@@ -76,6 +80,7 @@
7680
"@eslint/js": "^9.12.0",
7781
"@tsconfig/node18": "^18.2.2",
7882
"@types/bcryptjs": "^2.4.2",
83+
"@types/better-sqlite3": "^7.6.4",
7984
"@types/cookie": "^1.0.0",
8085
"@types/cookie-parser": "^1.4.2",
8186
"@types/cors": "^2.8.10",
@@ -90,9 +95,11 @@
9095
"@types/node": "~18.18.0",
9196
"@types/node-fetch": "^2.5.8",
9297
"@types/nodemailer": "^6.4.1",
98+
"@types/object.groupby": "^1.0.3",
9399
"@types/passport": "^1.0.6",
94100
"@types/passport-google-oauth20": "^2.0.7",
95101
"@types/passport-local": "^1.0.33",
102+
"@types/pg": "^8.6.6",
96103
"@types/qs": "^6.9.6",
97104
"@types/random-string": "^0.2.0",
98105
"@types/ratelimiter": "^3.4.1",
@@ -112,7 +119,7 @@
112119
"mocha": "^10.0.0",
113120
"nock": "^13.2.0",
114121
"nodemon": "^3.0.1",
115-
"pino-colada": "^2.2.2",
122+
"pino-pretty": "^11.2.2",
116123
"recaptcha-test-keys": "^1.0.0",
117124
"sinon": "^19.0.2",
118125
"supertest": "^7.0.0",
@@ -123,8 +130,8 @@
123130
"scripts": {
124131
"lint": "eslint --cache .",
125132
"test": "npm run tests-only && npm run lint",
126-
"tests-only": "c8 --reporter lcov --src src mocha --exit",
133+
"tests-only": "c8 --reporter lcov --src src mocha",
127134
"types": "tsc -p tsconfig.json",
128-
"start": "nodemon dev/u-wave-dev-server.js | pino-colada"
135+
"start": "nodemon dev/u-wave-dev-server.js | pino-pretty"
129136
}
130137
}

src/AuthRegistry.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class AuthRegistry {
1515
}
1616

1717
/**
18-
* @param {import('./models/index.js').User} user
18+
* @param {import('./schema.js').User} user
1919
*/
2020
async createAuthToken(user) {
2121
const token = (await randomBytes(64)).toString('hex');
@@ -42,7 +42,7 @@ class AuthRegistry {
4242
throw err;
4343
}
4444

45-
return userID;
45+
return /** @type {import('./schema.js').UserID} */ (userID);
4646
}
4747
}
4848

src/HttpApi.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ function defaultCreatePasswordResetEmail({ token, requestUrl }) {
6565
* @prop {import('nodemailer').Transport} [mailTransport]
6666
* @prop {(options: { token: string, requestUrl: string }) =>
6767
* import('nodemailer').SendMailOptions} [createPasswordResetEmail]
68-
*
6968
* @typedef {object} HttpApiSettings - Runtime options for the HTTP API.
7069
* @prop {string[]} allowedOrigins
7170
*/

src/SocketServer.js

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { promisify } from 'node:util';
2-
import mongoose from 'mongoose';
32
import lodash from 'lodash';
43
import sjson from 'secure-json-parse';
54
import { WebSocketServer } from 'ws';
@@ -15,10 +14,9 @@ import LostConnection from './sockets/LostConnection.js';
1514
import { serializeUser } from './utils/serialize.js';
1615

1716
const { debounce, isEmpty } = lodash;
18-
const { ObjectId } = mongoose.mongo;
1917

2018
/**
21-
* @typedef {import('./models/index.js').User} User
19+
* @typedef {import('./schema.js').User} User
2220
*/
2321

2422
/**
@@ -109,6 +107,7 @@ class SocketServer {
109107

110108
/**
111109
* Handlers for commands that come in from clients.
110+
*
112111
* @type {ClientActions}
113112
*/
114113
#clientActions;
@@ -206,7 +205,7 @@ class SocketServer {
206205
logout: (user, _, connection) => {
207206
this.replace(connection, this.createGuestConnection(connection.socket));
208207
if (!this.connection(user)) {
209-
disconnectUser(this.#uw, user._id);
208+
disconnectUser(this.#uw, user.id);
210209
}
211210
},
212211
};
@@ -231,7 +230,6 @@ class SocketServer {
231230
this.broadcast('advance', {
232231
historyID: next.historyID,
233232
userID: next.userID,
234-
itemID: next.itemID,
235233
media: next.media,
236234
playedAt: new Date(next.playedAt).getTime(),
237235
});
@@ -448,19 +446,22 @@ class SocketServer {
448446
/**
449447
* Create `LostConnection`s for every user that's known to be online, but that
450448
* is not currently connected to the socket server.
449+
*
451450
* @private
452451
*/
453452
async initLostConnections() {
454-
const { User } = this.#uw.models;
455-
const userIDs = await this.#uw.redis.lrange('users', 0, -1);
456-
const disconnectedIDs = userIDs
457-
.filter((userID) => !this.connection(userID))
458-
.map((userID) => new ObjectId(userID));
459-
460-
/** @type {User[]} */
461-
const disconnectedUsers = await User.find({
462-
_id: { $in: disconnectedIDs },
463-
}).exec();
453+
const { db, redis } = this.#uw;
454+
const userIDs = /** @type {import('./schema').UserID[]} */ (await redis.lrange('users', 0, -1));
455+
const disconnectedIDs = userIDs.filter((userID) => !this.connection(userID));
456+
457+
if (disconnectedIDs.length === 0) {
458+
return;
459+
}
460+
461+
const disconnectedUsers = await db.selectFrom('users')
462+
.where('id', 'in', disconnectedIDs)
463+
.selectAll()
464+
.execute();
464465
disconnectedUsers.forEach((user) => {
465466
this.add(this.createLostConnection(user));
466467
});
@@ -556,7 +557,7 @@ class SocketServer {
556557
connection.on('close', ({ banned }) => {
557558
if (banned) {
558559
this.#logger.info({ userId: user.id }, 'removing connection after ban');
559-
disconnectUser(this.#uw, user._id);
560+
disconnectUser(this.#uw, user.id);
560561
} else if (!this.#closing) {
561562
this.#logger.info({ userId: user.id }, 'lost connection');
562563
this.add(this.createLostConnection(user));
@@ -602,7 +603,7 @@ class SocketServer {
602603
// Only register that the user left if they didn't have another connection
603604
// still open.
604605
if (!this.connection(user)) {
605-
disconnectUser(this.#uw, user._id);
606+
disconnectUser(this.#uw, user.id);
606607
}
607608
});
608609
return connection;
@@ -659,7 +660,7 @@ class SocketServer {
659660
*
660661
* @param {string} channel
661662
* @param {string} rawCommand
662-
* @return {Promise<void>}
663+
* @returns {Promise<void>}
663664
* @private
664665
*/
665666
async onServerMessage(channel, rawCommand) {
@@ -686,7 +687,7 @@ class SocketServer {
686687
/**
687688
* Stop the socket server.
688689
*
689-
* @return {Promise<void>}
690+
* @returns {Promise<void>}
690691
*/
691692
async destroy() {
692693
clearInterval(this.#pinger);
@@ -707,7 +708,7 @@ class SocketServer {
707708
* Get the connection instance for a specific user.
708709
*
709710
* @param {User|string} user The user.
710-
* @return {Connection|undefined}
711+
* @returns {Connection|undefined}
711712
*/
712713
connection(user) {
713714
const userID = typeof user === 'object' ? user.id : user;

src/Source.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,41 @@
11
import { SourceNoImportError } from './errors/index.js';
22

33
/**
4-
* @typedef {import('./models/index.js').User} User
5-
* @typedef {import('./models/index.js').Playlist} Playlist
4+
* @typedef {import('./schema.js').User} User
5+
* @typedef {import('./schema.js').Playlist} Playlist
66
* @typedef {import('./plugins/playlists.js').PlaylistItemDesc} PlaylistItemDesc
77
*/
88

9+
/**
10+
* @typedef {{
11+
* sourceType: string,
12+
* sourceID: string,
13+
* sourceData: import('type-fest').JsonObject | null,
14+
* artist: string,
15+
* title: string,
16+
* duration: number,
17+
* thumbnail: string,
18+
* }} SourceMedia
19+
*/
20+
921
/**
1022
* @typedef {object} SourcePluginV1
1123
* @prop {undefined|1} api
12-
* @prop {(ids: string[]) => Promise<PlaylistItemDesc[]>} get
13-
* @prop {(query: string, page: unknown, ...args: unknown[]) => Promise<PlaylistItemDesc[]>} search
24+
* @prop {(ids: string[]) => Promise<SourceMedia[]>} get
25+
* @prop {(query: string, page: unknown, ...args: unknown[]) => Promise<SourceMedia[]>} search
1426
* @prop {(context: ImportContext, ...args: unknown[]) => Promise<unknown>} [import]
15-
*
1627
* @typedef {object} SourcePluginV2
1728
* @prop {2} api
18-
* @prop {(context: SourceContext, ids: string[]) => Promise<PlaylistItemDesc[]>} get
29+
* @prop {(context: SourceContext, ids: string[]) => Promise<SourceMedia[]>} get
1930
* @prop {(
2031
* context: SourceContext,
2132
* query: string,
2233
* page: unknown,
2334
* ...args: unknown[]
24-
* ) => Promise<PlaylistItemDesc[]>} search
35+
* ) => Promise<SourceMedia[]>} search
2536
* @prop {(context: ImportContext, ...args: unknown[]) => Promise<unknown>} [import]
2637
* @prop {(context: SourceContext, entry: PlaylistItemDesc) =>
2738
* Promise<import('type-fest').JsonObject>} [play]
28-
*
2939
* @typedef {SourcePluginV1 | SourcePluginV2} SourcePlugin
3040
*/
3141

@@ -61,7 +71,7 @@ class ImportContext extends SourceContext {
6171
* @returns {Promise<Playlist>} Playlist model.
6272
*/
6373
async createPlaylist(name, itemOrItems) {
64-
const playlist = await this.uw.playlists.createPlaylist(this.user, { name });
74+
const { playlist } = await this.uw.playlists.createPlaylist(this.user, { name });
6575

6676
const rawItems = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
6777
const items = this.source.addSourceType(rawItems);
@@ -101,8 +111,9 @@ class Source {
101111
* Media items can provide their own sourceType, too, so media sources can
102112
* aggregate items from different source types.
103113
*
104-
* @param {Omit<PlaylistItemDesc, 'sourceType'>[]} items
105-
* @returns {PlaylistItemDesc[]}
114+
* @template T
115+
* @param {T[]} items
116+
* @returns {(T & { sourceType: string })[]}
106117
*/
107118
addSourceType(items) {
108119
return items.map((item) => ({
@@ -116,7 +127,7 @@ class Source {
116127
*
117128
* @param {User} user
118129
* @param {string} id
119-
* @returns {Promise<PlaylistItemDesc?>}
130+
* @returns {Promise<SourceMedia?>}
120131
*/
121132
getOne(user, id) {
122133
return this.get(user, [id])
@@ -128,7 +139,7 @@ class Source {
128139
*
129140
* @param {User} user
130141
* @param {string[]} ids
131-
* @returns {Promise<PlaylistItemDesc[]>}
142+
* @returns {Promise<SourceMedia[]>}
132143
*/
133144
async get(user, ids) {
134145
let items;
@@ -150,7 +161,7 @@ class Source {
150161
* @param {string} query
151162
* @param {TPagination} [page]
152163
* @param {unknown[]} args
153-
* @returns {Promise<PlaylistItemDesc[]>}
164+
* @returns {Promise<SourceMedia[]>}
154165
*/
155166
async search(user, query, page, ...args) {
156167
let results;

0 commit comments

Comments
 (0)