Remove SQLite support

This commit is contained in:
Alex Gleason 2024-09-11 11:08:33 -05:00
parent f76d0af16d
commit dc8d09a9da
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
28 changed files with 156 additions and 568 deletions

View file

@ -42,4 +42,3 @@ postgres:
DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz DITTO_NSEC: nsec1zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs4rm7hz
DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_HOST_AUTH_METHOD: trust
ALLOW_TO_USE_DATABASE_URL: true

View file

@ -27,7 +27,6 @@
"@/": "./src/", "@/": "./src/",
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47", "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4", "@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
"@db/sqlite": "jsr:@db/sqlite@^0.11.1",
"@electric-sql/pglite": "npm:@soapbox.pub/pglite@^0.2.10", "@electric-sql/pglite": "npm:@soapbox.pub/pglite@^0.2.10",
"@hono/hono": "jsr:@hono/hono@^4.4.6", "@hono/hono": "jsr:@hono/hono@^4.4.6",
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
@ -37,7 +36,6 @@
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.1", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.30.1",
"@scure/base": "npm:@scure/base@^1.1.6", "@scure/base": "npm:@scure/base@^1.1.6",
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
"@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
"@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^0.0.1", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^0.0.1",
"@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0", "@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0",
"@std/assert": "jsr:@std/assert@^0.225.1", "@std/assert": "jsr:@std/assert@^0.225.1",

65
deno.lock generated
View file

@ -4,8 +4,6 @@
"specifiers": { "specifiers": {
"jsr:@b-fuze/deno-dom@^0.1.47": "jsr:@b-fuze/deno-dom@0.1.48", "jsr:@b-fuze/deno-dom@^0.1.47": "jsr:@b-fuze/deno-dom@0.1.48",
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4": "jsr:@bradenmacdonald/s3-lite-client@0.7.6", "jsr:@bradenmacdonald/s3-lite-client@^0.7.4": "jsr:@bradenmacdonald/s3-lite-client@0.7.6",
"jsr:@db/sqlite@^0.11.1": "jsr:@db/sqlite@0.11.1",
"jsr:@denosaurs/plug@1": "jsr:@denosaurs/plug@1.0.6",
"jsr:@denosaurs/plug@1.0.3": "jsr:@denosaurs/plug@1.0.3", "jsr:@denosaurs/plug@1.0.3": "jsr:@denosaurs/plug@1.0.3",
"jsr:@gleasonator/policy": "jsr:@gleasonator/policy@0.2.0", "jsr:@gleasonator/policy": "jsr:@gleasonator/policy@0.2.0",
"jsr:@gleasonator/policy@0.2.0": "jsr:@gleasonator/policy@0.2.0", "jsr:@gleasonator/policy@0.2.0": "jsr:@gleasonator/policy@0.2.0",
@ -25,12 +23,9 @@
"jsr:@nostrify/policies@^0.33.0": "jsr:@nostrify/policies@0.33.0", "jsr:@nostrify/policies@^0.33.0": "jsr:@nostrify/policies@0.33.0",
"jsr:@nostrify/types@^0.30.0": "jsr:@nostrify/types@0.30.1", "jsr:@nostrify/types@^0.30.0": "jsr:@nostrify/types@0.30.1",
"jsr:@nostrify/types@^0.30.1": "jsr:@nostrify/types@0.30.1", "jsr:@nostrify/types@^0.30.1": "jsr:@nostrify/types@0.30.1",
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0": "jsr:@soapbox/kysely-deno-sqlite@2.2.0",
"jsr:@soapbox/kysely-pglite@^0.0.1": "jsr:@soapbox/kysely-pglite@0.0.1", "jsr:@soapbox/kysely-pglite@^0.0.1": "jsr:@soapbox/kysely-pglite@0.0.1",
"jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0", "jsr:@soapbox/stickynotes@^0.4.0": "jsr:@soapbox/stickynotes@0.4.0",
"jsr:@std/assert@^0.213.1": "jsr:@std/assert@0.213.1", "jsr:@std/assert@^0.213.1": "jsr:@std/assert@0.213.1",
"jsr:@std/assert@^0.217.0": "jsr:@std/assert@0.217.0",
"jsr:@std/assert@^0.221.0": "jsr:@std/assert@0.221.0",
"jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0", "jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0",
"jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3", "jsr:@std/assert@^0.225.1": "jsr:@std/assert@0.225.3",
"jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0", "jsr:@std/bytes@^0.224.0": "jsr:@std/bytes@0.224.0",
@ -41,22 +36,17 @@
"jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0", "jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0",
"jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2", "jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2",
"jsr:@std/encoding@0.213.1": "jsr:@std/encoding@0.213.1", "jsr:@std/encoding@0.213.1": "jsr:@std/encoding@0.213.1",
"jsr:@std/encoding@^0.221.0": "jsr:@std/encoding@0.221.0",
"jsr:@std/encoding@^0.224.0": "jsr:@std/encoding@0.224.3", "jsr:@std/encoding@^0.224.0": "jsr:@std/encoding@0.224.3",
"jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3", "jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3",
"jsr:@std/fmt@0.213.1": "jsr:@std/fmt@0.213.1", "jsr:@std/fmt@0.213.1": "jsr:@std/fmt@0.213.1",
"jsr:@std/fmt@^0.221.0": "jsr:@std/fmt@0.221.0",
"jsr:@std/fs@0.213.1": "jsr:@std/fs@0.213.1", "jsr:@std/fs@0.213.1": "jsr:@std/fs@0.213.1",
"jsr:@std/fs@^0.221.0": "jsr:@std/fs@0.221.0",
"jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3", "jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3",
"jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.1", "jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.1",
"jsr:@std/io@^0.224": "jsr:@std/io@0.224.7", "jsr:@std/io@^0.224": "jsr:@std/io@0.224.7",
"jsr:@std/json@^0.223.0": "jsr:@std/json@0.223.0", "jsr:@std/json@^0.223.0": "jsr:@std/json@0.223.0",
"jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1", "jsr:@std/media-types@^0.224.1": "jsr:@std/media-types@0.224.1",
"jsr:@std/path@0.213.1": "jsr:@std/path@0.213.1", "jsr:@std/path@0.213.1": "jsr:@std/path@0.213.1",
"jsr:@std/path@0.217": "jsr:@std/path@0.217.0",
"jsr:@std/path@^0.213.1": "jsr:@std/path@0.213.1", "jsr:@std/path@^0.213.1": "jsr:@std/path@0.213.1",
"jsr:@std/path@^0.221.0": "jsr:@std/path@0.221.0",
"jsr:@std/streams@^0.223.0": "jsr:@std/streams@0.223.0", "jsr:@std/streams@^0.223.0": "jsr:@std/streams@0.223.0",
"npm:@isaacs/ttlcache@^1.4.1": "npm:@isaacs/ttlcache@1.4.1", "npm:@isaacs/ttlcache@^1.4.1": "npm:@isaacs/ttlcache@1.4.1",
"npm:@noble/hashes@^1.4.0": "npm:@noble/hashes@1.4.0", "npm:@noble/hashes@^1.4.0": "npm:@noble/hashes@1.4.0",
@ -116,13 +106,6 @@
"jsr:@std/io@^0.224" "jsr:@std/io@^0.224"
] ]
}, },
"@db/sqlite@0.11.1": {
"integrity": "546434e7ed762db07e6ade0f963540dd5e06723b802937bf260ff855b21ef9c5",
"dependencies": [
"jsr:@denosaurs/plug@1",
"jsr:@std/path@0.217"
]
},
"@denosaurs/plug@1.0.3": { "@denosaurs/plug@1.0.3": {
"integrity": "b010544e386bea0ff3a1d05e0c88f704ea28cbd4d753439c2f1ee021a85d4640", "integrity": "b010544e386bea0ff3a1d05e0c88f704ea28cbd4d753439c2f1ee021a85d4640",
"dependencies": [ "dependencies": [
@ -132,15 +115,6 @@
"jsr:@std/path@0.213.1" "jsr:@std/path@0.213.1"
] ]
}, },
"@denosaurs/plug@1.0.6": {
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
"dependencies": [
"jsr:@std/encoding@^0.221.0",
"jsr:@std/fmt@^0.221.0",
"jsr:@std/fs@^0.221.0",
"jsr:@std/path@^0.221.0"
]
},
"@gleasonator/policy@0.2.0": { "@gleasonator/policy@0.2.0": {
"integrity": "3fe58b853ab203b2b67e65b64391dbcf5c07bc1caaf46e97b2f8ed5b14f30fdf", "integrity": "3fe58b853ab203b2b67e65b64391dbcf5c07bc1caaf46e97b2f8ed5b14f30fdf",
"dependencies": [ "dependencies": [
@ -293,12 +267,6 @@
"@nostrify/types@0.30.1": { "@nostrify/types@0.30.1": {
"integrity": "245da176f6893a43250697db51ad32bfa29bf9b1cdc1ca218043d9abf6de5ae5" "integrity": "245da176f6893a43250697db51ad32bfa29bf9b1cdc1ca218043d9abf6de5ae5"
}, },
"@soapbox/kysely-deno-sqlite@2.2.0": {
"integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a",
"dependencies": [
"npm:kysely@^0.27.2"
]
},
"@soapbox/kysely-pglite@0.0.1": { "@soapbox/kysely-pglite@0.0.1": {
"integrity": "7a4221aa780aad6fba9747c45c59dfb1c62017ba8cad9db5607f6e5822c058d5", "integrity": "7a4221aa780aad6fba9747c45c59dfb1c62017ba8cad9db5607f6e5822c058d5",
"dependencies": [ "dependencies": [
@ -311,12 +279,6 @@
"@std/assert@0.213.1": { "@std/assert@0.213.1": {
"integrity": "24c28178b30c8e0782c18e8e94ea72b16282207569cdd10ffb9d1d26f2edebfe" "integrity": "24c28178b30c8e0782c18e8e94ea72b16282207569cdd10ffb9d1d26f2edebfe"
}, },
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@0.221.0": {
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
},
"@std/assert@0.224.0": { "@std/assert@0.224.0": {
"integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f" "integrity": "8643233ec7aec38a940a8264a6e3eed9bfa44e7a71cc6b3c8874213ff401967f"
}, },
@ -351,18 +313,12 @@
"@std/encoding@0.213.1": { "@std/encoding@0.213.1": {
"integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62" "integrity": "fcbb6928713dde941a18ca5db88ca1544d0755ec8fb20fe61e2dc8144b390c62"
}, },
"@std/encoding@0.221.0": {
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
},
"@std/encoding@0.224.3": { "@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
}, },
"@std/fmt@0.213.1": { "@std/fmt@0.213.1": {
"integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3" "integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3"
}, },
"@std/fmt@0.221.0": {
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
},
"@std/fs@0.213.1": { "@std/fs@0.213.1": {
"integrity": "fbcaf099f8a85c27ab0712b666262cda8fe6d02e9937bf9313ecaea39a22c501", "integrity": "fbcaf099f8a85c27ab0712b666262cda8fe6d02e9937bf9313ecaea39a22c501",
"dependencies": [ "dependencies": [
@ -370,13 +326,6 @@
"jsr:@std/path@^0.213.1" "jsr:@std/path@^0.213.1"
] ]
}, },
"@std/fs@0.221.0": {
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
"dependencies": [
"jsr:@std/assert@^0.221.0",
"jsr:@std/path@^0.221.0"
]
},
"@std/fs@0.229.3": { "@std/fs@0.229.3": {
"integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb" "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb"
}, },
@ -434,18 +383,6 @@
"jsr:@std/assert@^0.213.1" "jsr:@std/assert@^0.213.1"
] ]
}, },
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert@^0.217.0"
]
},
"@std/path@0.221.0": {
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
"dependencies": [
"jsr:@std/assert@^0.221.0"
]
},
"@std/streams@0.223.0": { "@std/streams@0.223.0": {
"integrity": "d6b28e498ced3960b04dc5d251f2dcfc1df244b5ec5a48dc23a8f9b490be3b99" "integrity": "d6b28e498ced3960b04dc5d251f2dcfc1df244b5ec5a48dc23a8f9b490be3b99"
} }
@ -1972,12 +1909,10 @@
"dependencies": [ "dependencies": [
"jsr:@b-fuze/deno-dom@^0.1.47", "jsr:@b-fuze/deno-dom@^0.1.47",
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4", "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
"jsr:@db/sqlite@^0.11.1",
"jsr:@hono/hono@^4.4.6", "jsr:@hono/hono@^4.4.6",
"jsr:@lambdalisue/async@^2.1.1", "jsr:@lambdalisue/async@^2.1.1",
"jsr:@nostrify/db@^0.31.2", "jsr:@nostrify/db@^0.31.2",
"jsr:@nostrify/nostrify@^0.30.1", "jsr:@nostrify/nostrify@^0.30.1",
"jsr:@soapbox/kysely-deno-sqlite@^2.1.0",
"jsr:@soapbox/kysely-pglite@^0.0.1", "jsr:@soapbox/kysely-pglite@^0.0.1",
"jsr:@soapbox/stickynotes@^0.4.0", "jsr:@soapbox/stickynotes@^0.4.0",
"jsr:@std/assert@^0.225.1", "jsr:@std/assert@^0.225.1",

View file

@ -16,9 +16,9 @@ ssh -L 9229:localhost:9229 <user>@<host>
Then, in Chromium, go to `chrome://inspect` and the Ditto server should be available. Then, in Chromium, go to `chrome://inspect` and the Ditto server should be available.
## SQLite performance ## SQL performance
To track slow queries, first set `DEBUG=ditto:sqlite.worker` in the environment so only SQLite logs are shown. To track slow queries, first set `DEBUG=ditto:sql` in the environment so only SQL logs are shown.
Then, grep for any logs above 0.001s: Then, grep for any logs above 0.001s:

View file

@ -45,10 +45,10 @@ const DATABASE_URL = Deno.env.get('DATABASE_URL');
if (DATABASE_URL) { if (DATABASE_URL) {
vars.DATABASE_URL = await question('input', 'Database URL', DATABASE_URL); vars.DATABASE_URL = await question('input', 'Database URL', DATABASE_URL);
} else { } else {
const database = await question('list', 'Which database do you want to use?', ['postgres', 'sqlite']); const database = await question('list', 'Which database do you want to use?', ['postgres', 'pglite']);
if (database === 'sqlite') { if (database === 'pglite') {
const path = await question('input', 'Path to SQLite database', 'data/db.sqlite3'); const path = await question('input', 'Path to PGlite data directory', 'data/pgdata');
vars.DATABASE_URL = `sqlite://${path}`; vars.DATABASE_URL = `file://${path}`;
} }
if (database === 'postgres') { if (database === 'postgres') {
const host = await question('input', 'Postgres host', 'localhost'); const host = await question('input', 'Postgres host', 'localhost');

View file

@ -1,5 +1,3 @@
import url from 'node:url';
import * as dotenv from '@std/dotenv'; import * as dotenv from '@std/dotenv';
import { getPublicKey, nip19 } from 'nostr-tools'; import { getPublicKey, nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
@ -89,20 +87,6 @@ class Conf {
return Deno.env.get('TEST_DATABASE_URL') ?? 'memory://'; return Deno.env.get('TEST_DATABASE_URL') ?? 'memory://';
} }
static db = { static db = {
get url(): url.UrlWithStringQuery {
return url.parse(Conf.databaseUrl);
},
get dialect(): 'sqlite' | 'postgres' | undefined {
switch (Conf.db.url.protocol) {
case 'sqlite:':
return 'sqlite';
case 'pglite:':
case 'postgres:':
case 'postgresql:':
return 'postgres';
}
return undefined;
},
/** Database query timeout configurations. */ /** Database query timeout configurations. */
timeouts: { timeouts: {
/** Default query timeout when another setting isn't more specific. */ /** Default query timeout when another setting isn't more specific. */
@ -221,21 +205,6 @@ class Conf {
static get sentryDsn(): string | undefined { static get sentryDsn(): string | undefined {
return Deno.env.get('SENTRY_DSN'); return Deno.env.get('SENTRY_DSN');
} }
/** SQLite settings. */
static sqlite = {
/**
* Number of bytes to use for memory-mapped IO.
* https://www.sqlite.org/pragma.html#pragma_mmap_size
*/
get mmapSize(): number {
const value = Deno.env.get('SQLITE_MMAP_SIZE');
if (value) {
return Number(value);
} else {
return 1024 * 1024 * 1024;
}
},
};
/** Postgres settings. */ /** Postgres settings. */
static pg = { static pg = {
/** Number of connections to use in the pool. */ /** Number of connections to use in the pool. */

View file

@ -82,7 +82,7 @@ const createTokenController: AppController = async (c) => {
async function getToken( async function getToken(
{ pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, { pubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
): Promise<`token1${string}`> { ): Promise<`token1${string}`> {
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const token = generateToken(); const token = generateToken();
const serverSeckey = generateSecretKey(); const serverSeckey = generateSecretKey();

View file

@ -578,7 +578,7 @@ const zappedByController: AppController = async (c) => {
const id = c.req.param('id'); const id = c.req.param('id');
const params = c.get('listPagination'); const params = c.get('listPagination');
const store = await Storages.db(); const store = await Storages.db();
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const zaps = await kysely.selectFrom('event_zaps') const zaps = await kysely.selectFrom('event_zaps')
.selectAll() .selectAll()

View file

@ -222,7 +222,7 @@ async function topicToFilter(
async function getTokenPubkey(token: string): Promise<string | undefined> { async function getTokenPubkey(token: string): Promise<string | undefined> {
if (token.startsWith('token1')) { if (token.startsWith('token1')) {
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const { user_pubkey } = await kysely const { user_pubkey } = await kysely
.selectFrom('nip46_tokens') .selectFrom('nip46_tokens')

View file

@ -1,75 +1,66 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { NDatabaseSchema, NPostgresSchema } from '@nostrify/db';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; import { FileMigrationProvider, Kysely, Migrator } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoPglite } from '@/db/adapters/DittoPglite.ts'; import { DittoPglite } from '@/db/adapters/DittoPglite.ts';
import { DittoPostgres } from '@/db/adapters/DittoPostgres.ts'; import { DittoPostgres } from '@/db/adapters/DittoPostgres.ts';
import { DittoSQLite } from '@/db/adapters/DittoSQLite.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
export type DittoDatabase = {
dialect: 'sqlite';
kysely: Kysely<DittoTables> & Kysely<NDatabaseSchema>;
} | {
dialect: 'postgres';
kysely: Kysely<DittoTables> & Kysely<NPostgresSchema>;
};
export class DittoDB { export class DittoDB {
private static db: Promise<DittoDatabase> | undefined; private static kysely: Promise<Kysely<DittoTables>> | undefined;
static getInstance(): Promise<DittoDatabase> { static getInstance(): Promise<Kysely<DittoTables>> {
if (!this.db) { if (!this.kysely) {
this.db = this._getInstance(); this.kysely = this._getInstance();
} }
return this.db; return this.kysely;
} }
static async _getInstance(): Promise<DittoDatabase> { static async _getInstance(): Promise<Kysely<DittoTables>> {
const result = {} as DittoDatabase; const { protocol } = new URL(Conf.databaseUrl);
switch (Conf.db.url.protocol) { let kysely: Kysely<DittoTables>;
case 'sqlite:':
result.dialect = 'sqlite'; switch (protocol) {
result.kysely = await DittoSQLite.getInstance(); case 'file:':
break; case 'memory:':
case 'pglite:': kysely = await DittoPglite.getInstance();
result.dialect = 'postgres';
result.kysely = await DittoPglite.getInstance();
break; break;
case 'postgres:': case 'postgres:':
case 'postgresql:': case 'postgresql:':
result.dialect = 'postgres'; kysely = await DittoPostgres.getInstance();
result.kysely = await DittoPostgres.getInstance();
break; break;
default: default:
throw new Error('Unsupported database URL.'); throw new Error('Unsupported database URL.');
} }
await this.migrate(result.kysely); await this.migrate(kysely);
return result; return kysely;
} }
static get poolSize(): number { static get poolSize(): number {
if (Conf.db.dialect === 'postgres') { const { protocol } = new URL(Conf.databaseUrl);
if (['postgres:', 'postgresql:'].includes(protocol)) {
return DittoPostgres.poolSize; return DittoPostgres.poolSize;
} }
return 1; return 1;
} }
static get availableConnections(): number { static get availableConnections(): number {
if (Conf.db.dialect === 'postgres') { const { protocol } = new URL(Conf.databaseUrl);
if (['postgres:', 'postgresql:'].includes(protocol)) {
return DittoPostgres.availableConnections; return DittoPostgres.availableConnections;
} }
return 1; return 1;
} }
/** Migrate the database to the latest version. */ /** Migrate the database to the latest version. */
static async migrate(kysely: DittoDatabase['kysely']) { static async migrate(kysely: Kysely<DittoTables>) {
const migrator = new Migrator({ const migrator = new Migrator({
db: kysely, db: kysely,
provider: new FileMigrationProvider({ provider: new FileMigrationProvider({

View file

@ -1,4 +1,6 @@
export interface DittoTables { import { NPostgresSchema } from '@nostrify/db';
export interface DittoTables extends NPostgresSchema {
nip46_tokens: NIP46TokenRow; nip46_tokens: NIP46TokenRow;
author_stats: AuthorStatsRow; author_stats: AuthorStatsRow;
event_stats: EventStatsRow; event_stats: EventStatsRow;

View file

@ -1,5 +1,4 @@
import { PGlite } from '@electric-sql/pglite'; import { PGlite } from '@electric-sql/pglite';
import { NPostgresSchema } from '@nostrify/db';
import { PgliteDialect } from '@soapbox/kysely-pglite'; import { PgliteDialect } from '@soapbox/kysely-pglite';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
@ -8,17 +7,17 @@ import { DittoTables } from '@/db/DittoTables.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts'; import { KyselyLogger } from '@/db/KyselyLogger.ts';
export class DittoPglite { export class DittoPglite {
static db: Kysely<DittoTables> & Kysely<NPostgresSchema> | undefined; static db: Kysely<DittoTables> | undefined;
// deno-lint-ignore require-await // deno-lint-ignore require-await
static async getInstance(): Promise<Kysely<DittoTables> & Kysely<NPostgresSchema>> { static async getInstance(): Promise<Kysely<DittoTables>> {
if (!this.db) { if (!this.db) {
this.db = new Kysely({ this.db = new Kysely<DittoTables>({
dialect: new PgliteDialect({ dialect: new PgliteDialect({
database: new PGlite(this.path), database: new PGlite(Conf.databaseUrl),
}), }),
log: KyselyLogger, log: KyselyLogger,
}) as Kysely<DittoTables> & Kysely<NPostgresSchema>; }) as Kysely<DittoTables>;
} }
return this.db; return this.db;
@ -31,26 +30,4 @@ export class DittoPglite {
static get availableConnections(): number { static get availableConnections(): number {
return 1; return 1;
} }
/** Get the relative or absolute path based on the `DATABASE_URL`. */
static get path(): string | undefined {
if (Conf.databaseUrl === 'pglite://:memory:') {
return undefined;
}
const { host, pathname } = Conf.db.url;
if (!pathname) return '';
// Get relative path.
if (host === '') {
return pathname;
} else if (host === '.') {
return pathname;
} else if (host) {
return host + pathname;
}
return '';
}
} }

View file

@ -1,4 +1,3 @@
import { NPostgresSchema } from '@nostrify/db';
import { import {
BinaryOperationNode, BinaryOperationNode,
FunctionNode, FunctionNode,
@ -18,17 +17,17 @@ import { DittoTables } from '@/db/DittoTables.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts'; import { KyselyLogger } from '@/db/KyselyLogger.ts';
export class DittoPostgres { export class DittoPostgres {
static db: Kysely<DittoTables> & Kysely<NPostgresSchema> | undefined; static kysely: Kysely<DittoTables> | undefined;
static postgres?: postgres.Sql; static postgres?: postgres.Sql;
// deno-lint-ignore require-await // deno-lint-ignore require-await
static async getInstance(): Promise<Kysely<DittoTables> & Kysely<NPostgresSchema>> { static async getInstance(): Promise<Kysely<DittoTables>> {
if (!this.postgres) { if (!this.postgres) {
this.postgres = postgres(Conf.databaseUrl, { max: Conf.pg.poolSize }); this.postgres = postgres(Conf.databaseUrl, { max: Conf.pg.poolSize });
} }
if (!this.db) { if (!this.kysely) {
this.db = new Kysely({ this.kysely = new Kysely<DittoTables>({
dialect: { dialect: {
createAdapter() { createAdapter() {
return new PostgresAdapter(); return new PostgresAdapter();
@ -46,10 +45,10 @@ export class DittoPostgres {
}, },
}, },
log: KyselyLogger, log: KyselyLogger,
}) as Kysely<DittoTables> & Kysely<NPostgresSchema>; });
} }
return this.db; return this.kysely;
} }
static get poolSize() { static get poolSize() {

View file

@ -1,59 +0,0 @@
import { NDatabaseSchema } from '@nostrify/db';
import { PolySqliteDialect } from '@soapbox/kysely-deno-sqlite';
import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts';
import SqliteWorker from '@/workers/sqlite.ts';
export class DittoSQLite {
static db: Kysely<DittoTables> & Kysely<NDatabaseSchema> | undefined;
static async getInstance(): Promise<Kysely<DittoTables> & Kysely<NDatabaseSchema>> {
if (!this.db) {
const sqliteWorker = new SqliteWorker();
await sqliteWorker.open(this.path);
this.db = new Kysely({
dialect: new PolySqliteDialect({
database: sqliteWorker,
}),
log: KyselyLogger,
}) as Kysely<DittoTables> & Kysely<NDatabaseSchema>;
// Set PRAGMA values.
await Promise.all([
sql`PRAGMA synchronous = normal`.execute(this.db),
sql`PRAGMA temp_store = memory`.execute(this.db),
sql`PRAGMA foreign_keys = ON`.execute(this.db),
sql`PRAGMA auto_vacuum = FULL`.execute(this.db),
sql`PRAGMA journal_mode = WAL`.execute(this.db),
sql.raw(`PRAGMA mmap_size = ${Conf.sqlite.mmapSize}`).execute(this.db),
]);
}
return this.db;
}
/** Get the relative or absolute path based on the `DATABASE_URL`. */
static get path() {
if (Conf.databaseUrl === 'sqlite://:memory:') {
return ':memory:';
}
const { host, pathname } = Conf.db.url;
if (!pathname) return '';
// Get relative path.
if (host === '') {
return pathname;
} else if (host === '.') {
return pathname;
} else if (host) {
return host + pathname;
}
return '';
}
}

View file

@ -1,13 +1,8 @@
import { Kysely, sql } from 'kysely'; import { Kysely } from 'kysely';
import { Conf } from '@/config.ts'; export async function up(_db: Kysely<any>): Promise<void> {
// This migration used to create an FTS table for SQLite, but SQLite support was removed.
export async function up(db: Kysely<any>): Promise<void> {
if (Conf.db.dialect === 'sqlite') {
await sql`CREATE VIRTUAL TABLE events_fts USING fts5(id, content)`.execute(db);
}
} }
export async function down(db: Kysely<any>): Promise<void> { export async function down(_db: Kysely<any>): Promise<void> {
await db.schema.dropTable('events_fts').ifExists().execute();
} }

View file

@ -1,25 +1,13 @@
import { Kysely, sql } from 'kysely'; import { Kysely } from 'kysely';
import { Conf } from '@/config.ts';
export async function up(db: Kysely<any>): Promise<void> { export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('events').renameTo('nostr_events').execute(); await db.schema.alterTable('events').renameTo('nostr_events').execute();
await db.schema.alterTable('tags').renameTo('nostr_tags').execute(); await db.schema.alterTable('tags').renameTo('nostr_tags').execute();
await db.schema.alterTable('nostr_tags').renameColumn('tag', 'name').execute(); await db.schema.alterTable('nostr_tags').renameColumn('tag', 'name').execute();
if (Conf.db.dialect === 'sqlite') {
await db.schema.dropTable('events_fts').execute();
await sql`CREATE VIRTUAL TABLE nostr_fts5 USING fts5(event_id, content)`.execute(db);
}
} }
export async function down(db: Kysely<any>): Promise<void> { export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('nostr_events').renameTo('events').execute(); await db.schema.alterTable('nostr_events').renameTo('events').execute();
await db.schema.alterTable('nostr_tags').renameTo('tags').execute(); await db.schema.alterTable('nostr_tags').renameTo('tags').execute();
await db.schema.alterTable('tags').renameColumn('name', 'tag').execute(); await db.schema.alterTable('tags').renameColumn('name', 'tag').execute();
if (Conf.db.dialect === 'sqlite') {
await db.schema.dropTable('nostr_fts5').execute();
await sql`CREATE VIRTUAL TABLE events_fts USING fts5(id, content)`.execute(db);
}
} }

View file

@ -20,7 +20,7 @@ export const signerMiddleware: AppMiddleware = async (c, next) => {
if (bech32.startsWith('token1')) { if (bech32.startsWith('token1')) {
try { try {
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const { user_pubkey, server_seckey, relays } = await kysely const { user_pubkey, server_seckey, relays } = await kysely
.selectFrom('nip46_tokens') .selectFrom('nip46_tokens')

View file

@ -53,7 +53,7 @@ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise<void
throw new RelayError('blocked', 'user is disabled'); throw new RelayError('blocked', 'user is disabled');
} }
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
await Promise.all([ await Promise.all([
storeEvent(event, signal), storeEvent(event, signal),
@ -104,7 +104,7 @@ async function existsInDB(event: DittoEvent): Promise<boolean> {
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> { async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
await hydrateEvents({ events: [event], store: await Storages.db(), signal }); await hydrateEvents({ events: [event], store: await Storages.db(), signal });
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const domain = await kysely const domain = await kysely
.selectFrom('pubkey_domains') .selectFrom('pubkey_domains')
.select('domain') .select('domain')
@ -118,7 +118,7 @@ async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<voi
async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<undefined> { async function storeEvent(event: DittoEvent, signal?: AbortSignal): Promise<undefined> {
if (NKinds.ephemeral(event.kind)) return; if (NKinds.ephemeral(event.kind)) return;
const store = await Storages.db(); const store = await Storages.db();
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
await updateStats({ event, store, kysely }).catch(debug); await updateStats({ event, store, kysely }).catch(debug);
await store.event(event, { signal }); await store.event(event, { signal });
@ -146,7 +146,7 @@ async function parseMetadata(event: NostrEvent, signal: AbortSignal): Promise<vo
// Track pubkey domain. // Track pubkey domain.
try { try {
const { kysely } = await DittoDB.getInstance(); const kysely = await DittoDB.getInstance();
const { domain } = parseNip05(nip05); const { domain } = parseNip05(nip05);
await sql` await sql`

View file

@ -16,7 +16,7 @@ export class Storages {
private static _pubsub: Promise<InternalRelay> | undefined; private static _pubsub: Promise<InternalRelay> | undefined;
private static _search: Promise<SearchStore> | undefined; private static _search: Promise<SearchStore> | undefined;
/** SQLite database to store events this Ditto server cares about. */ /** SQL database to store events this Ditto server cares about. */
public static async db(): Promise<EventsDB> { public static async db(): Promise<EventsDB> {
if (!this._db) { if (!this._db) {
this._db = (async () => { this._db = (async () => {

View file

@ -13,10 +13,11 @@ import {
NStore, NStore,
} from '@nostrify/nostrify'; } from '@nostrify/nostrify';
import { Stickynotes } from '@soapbox/stickynotes'; import { Stickynotes } from '@soapbox/stickynotes';
import { Kysely } from 'kysely';
import { nip27 } from 'nostr-tools'; import { nip27 } from 'nostr-tools';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoDatabase } from '@/db/DittoDB.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { dbEventsCounter } from '@/metrics.ts'; import { dbEventsCounter } from '@/metrics.ts';
import { RelayError } from '@/RelayError.ts'; import { RelayError } from '@/RelayError.ts';
import { purifyEvent } from '@/storages/hydrate.ts'; import { purifyEvent } from '@/storages/hydrate.ts';
@ -30,7 +31,7 @@ type TagCondition = ({ event, count, value }: {
value: string; value: string;
}) => boolean; }) => boolean;
/** SQLite database storage adapter for Nostr events. */ /** SQL database storage adapter for Nostr events. */
class EventsDB implements NStore { class EventsDB implements NStore {
private store: NDatabase | NPostgres; private store: NDatabase | NPostgres;
private console = new Stickynotes('ditto:db:events'); private console = new Stickynotes('ditto:db:events');
@ -52,21 +53,11 @@ class EventsDB implements NStore {
't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50, 't': ({ event, count, value }) => (event.kind === 1985 ? count < 20 : count < 5) && value.length < 50,
}; };
constructor(private database: DittoDatabase) { constructor(private kysely: Kysely<DittoTables>) {
const { dialect, kysely } = database;
if (dialect === 'postgres') {
this.store = new NPostgres(kysely, { this.store = new NPostgres(kysely, {
indexTags: EventsDB.indexTags, indexTags: EventsDB.indexTags,
indexSearch: EventsDB.searchText, indexSearch: EventsDB.searchText,
}); });
} else {
this.store = new NDatabase(kysely, {
fts: 'sqlite',
indexTags: EventsDB.indexTags,
searchText: EventsDB.searchText,
});
}
} }
/** Insert an event (and its tags) into the database. */ /** Insert an event (and its tags) into the database. */
@ -273,7 +264,7 @@ class EventsDB implements NStore {
return tags.map(([_tag, value]) => value).join('\n'); return tags.map(([_tag, value]) => value).join('\n');
} }
/** Converts filters to more performant, simpler filters that are better for SQLite. */ /** Converts filters to more performant, simpler filters. */
async expandFilters(filters: NostrFilter[]): Promise<NostrFilter[]> { async expandFilters(filters: NostrFilter[]): Promise<NostrFilter[]> {
filters = structuredClone(filters); filters = structuredClone(filters);
@ -286,7 +277,7 @@ class EventsDB implements NStore {
) as { key: 'domain'; value: string } | undefined)?.value; ) as { key: 'domain'; value: string } | undefined)?.value;
if (domain) { if (domain) {
const query = this.database.kysely const query = this.kysely
.selectFrom('pubkey_domains') .selectFrom('pubkey_domains')
.select('pubkey') .select('pubkey')
.where('domain', '=', domain); .where('domain', '=', domain);

View file

@ -18,7 +18,7 @@ interface HydrateOpts {
/** Hydrate events using the provided storage. */ /** Hydrate events using the provided storage. */
async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> { async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
const { events, store, signal, kysely = (await DittoDB.getInstance()).kysely } = opts; const { events, store, signal, kysely = await DittoDB.getInstance() } = opts;
if (!events.length) { if (!events.length) {
return events; return events;

View file

@ -1,21 +1,17 @@
import fs from 'node:fs/promises'; import { PGlite } from '@electric-sql/pglite';
import path from 'node:path';
import { Database as Sqlite } from '@db/sqlite';
import { NDatabase, NDatabaseSchema, NPostgresSchema } from '@nostrify/db';
import { NostrEvent } from '@nostrify/nostrify'; import { NostrEvent } from '@nostrify/nostrify';
import { DenoSqlite3Dialect } from '@soapbox/kysely-deno-sqlite'; import { PgliteDialect } from '@soapbox/kysely-pglite';
import { finalizeEvent, generateSecretKey } from 'nostr-tools'; import { finalizeEvent, generateSecretKey } from 'nostr-tools';
import { FileMigrationProvider, Kysely, Migrator } from 'kysely'; import { Kysely } from 'kysely';
import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js'; import { PostgresJSDialect, PostgresJSDialectConfig } from 'kysely-postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
import { DittoDatabase, DittoDB } from '@/db/DittoDB.ts'; import { Conf } from '@/config.ts';
import { DittoDB } from '@/db/DittoDB.ts';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { purifyEvent } from '@/storages/hydrate.ts'; import { purifyEvent } from '@/storages/hydrate.ts';
import { KyselyLogger } from '@/db/KyselyLogger.ts'; import { KyselyLogger } from '@/db/KyselyLogger.ts';
import { EventsDB } from '@/storages/EventsDB.ts'; import { EventsDB } from '@/storages/EventsDB.ts';
import { Conf } from '@/config.ts';
/** Import an event fixture by name in tests. */ /** Import an event fixture by name in tests. */
export async function eventFixture(name: string): Promise<NostrEvent> { export async function eventFixture(name: string): Promise<NostrEvent> {
@ -42,97 +38,45 @@ export function genEvent(t: Partial<NostrEvent> = {}, sk: Uint8Array = generateS
return purifyEvent(event); return purifyEvent(event);
} }
/** Get an in-memory SQLite database to use for testing. It's automatically destroyed when it goes out of scope. */
export async function getTestDB() {
const kysely = new Kysely<DittoTables>({
dialect: new DenoSqlite3Dialect({
database: new Sqlite(':memory:'),
}),
});
const migrator = new Migrator({
db: kysely,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: new URL(import.meta.resolve('./db/migrations')).pathname,
}),
});
await migrator.migrateToLatest();
const store = new NDatabase(kysely);
return {
store,
kysely,
[Symbol.asyncDispose]: () => kysely.destroy(),
};
}
/** Create an database for testing. */ /** Create an database for testing. */
export const createTestDB = async (databaseUrl?: string) => { export const createTestDB = async (databaseUrl = Conf.testDatabaseUrl) => {
databaseUrl ??= Deno.env.get('DATABASE_URL') ?? 'sqlite://:memory:'; const { protocol } = new URL(databaseUrl);
let dialect: 'sqlite' | 'postgres' = (() => { const kysely: Kysely<DittoTables> = (() => {
const protocol = databaseUrl.split(':')[0];
switch (protocol) { switch (protocol) {
case 'sqlite': case 'postgres:':
return 'sqlite'; case 'postgresql:':
case 'postgres': return new Kysely({
return protocol;
case 'postgresql':
return 'postgres';
default:
throw new Error(`Unsupported protocol: ${protocol}`);
}
})();
const allowToUseDATABASE_URL = Deno.env.get('ALLOW_TO_USE_DATABASE_URL')?.toLowerCase() ?? '';
if (allowToUseDATABASE_URL !== 'true' && dialect === 'postgres') {
console.warn(
'%cRunning tests with sqlite, if you meant to use Postgres, run again with ALLOW_TO_USE_DATABASE_URL environment variable set to true',
'color: yellow;',
);
dialect = 'sqlite';
}
console.warn(`Using: ${dialect}`);
const db: DittoDatabase = { dialect } as DittoDatabase;
if (dialect === 'sqlite') {
// migration 021_pgfts_index.ts calls 'Conf.db.dialect',
// and this calls the DATABASE_URL environment variable.
// The following line ensures to NOT use the DATABASE_URL that may exist in an .env file.
Deno.env.set('DATABASE_URL', 'sqlite://:memory:');
db.kysely = new Kysely({
dialect: new DenoSqlite3Dialect({
database: new Sqlite(':memory:'),
}),
}) as Kysely<DittoTables> & Kysely<NDatabaseSchema>;
} else {
db.kysely = new Kysely({
// @ts-ignore Kysely version mismatch. // @ts-ignore Kysely version mismatch.
dialect: new PostgresJSDialect({ dialect: new PostgresJSDialect({
postgres: postgres(Conf.databaseUrl, { postgres: postgres(databaseUrl, {
max: Conf.pg.poolSize, max: Conf.pg.poolSize,
}) as unknown as PostgresJSDialectConfig['postgres'], }) as unknown as PostgresJSDialectConfig['postgres'],
}), }),
log: KyselyLogger, log: KyselyLogger,
}) as Kysely<DittoTables> & Kysely<NPostgresSchema>; });
case 'file:':
case 'memory:':
return new Kysely({
dialect: new PgliteDialect({
database: new PGlite(databaseUrl),
}),
});
default:
throw new Error(`Unsupported database URL protocol: ${protocol}`);
} }
})();
await DittoDB.migrate(db.kysely); await DittoDB.migrate(kysely);
const store = new EventsDB(db); const store = new EventsDB(kysely);
return { return {
dialect,
store, store,
kysely: db.kysely, kysely,
[Symbol.asyncDispose]: async () => { [Symbol.asyncDispose]: async () => {
if (dialect === 'postgres') { // If we're testing against real Postgres, we will reuse the database
// between tests, so we should drop the tables to keep each test fresh.
if (['postgres:', 'postgresql:'].includes(protocol)) {
for ( for (
const table of [ const table of [
'author_stats', 'author_stats',
@ -142,16 +86,13 @@ export const createTestDB = async (databaseUrl?: string) => {
'kysely_migration_lock', 'kysely_migration_lock',
'nip46_tokens', 'nip46_tokens',
'pubkey_domains', 'pubkey_domains',
'unattached_media',
'nostr_events', 'nostr_events',
'nostr_tags',
'nostr_pgfts',
'event_zaps', 'event_zaps',
] ]
) { ) {
await db.kysely.schema.dropTable(table).ifExists().cascade().execute(); await kysely.schema.dropTable(table).ifExists().cascade().execute();
} }
await db.kysely.destroy(); await kysely.destroy();
} }
}, },
}; };

View file

@ -1,9 +1,10 @@
import { NostrFilter } from '@nostrify/nostrify'; import { NostrFilter } from '@nostrify/nostrify';
import { Stickynotes } from '@soapbox/stickynotes'; import { Stickynotes } from '@soapbox/stickynotes';
import { sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { DittoDatabase, DittoDB } from '@/db/DittoDB.ts'; import { DittoDB } from '@/db/DittoDB.ts';
import { DittoTables } from '@/db/DittoTables.ts';
import { handleEvent } from '@/pipeline.ts'; import { handleEvent } from '@/pipeline.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Time } from '@/utils/time.ts'; import { Time } from '@/utils/time.ts';
@ -13,13 +14,12 @@ const console = new Stickynotes('ditto:trends');
/** Get trending tag values for a given tag in the given time frame. */ /** Get trending tag values for a given tag in the given time frame. */
export async function getTrendingTagValues( export async function getTrendingTagValues(
/** Kysely instance to execute queries on. */ /** Kysely instance to execute queries on. */
{ dialect, kysely }: DittoDatabase, kysely: Kysely<DittoTables>,
/** Tag name to filter by, eg `t` or `r`. */ /** Tag name to filter by, eg `t` or `r`. */
tagNames: string[], tagNames: string[],
/** Filter of eligible events. */ /** Filter of eligible events. */
filter: NostrFilter, filter: NostrFilter,
): Promise<{ value: string; authors: number; uses: number }[]> { ): Promise<{ value: string; authors: number; uses: number }[]> {
if (dialect === 'postgres') {
let query = kysely let query = kysely
.selectFrom([ .selectFrom([
'nostr_events', 'nostr_events',
@ -58,43 +58,6 @@ export async function getTrendingTagValues(
authors: Number(row.authors), authors: Number(row.authors),
uses: Number(row.uses), uses: Number(row.uses),
})); }));
}
if (dialect === 'sqlite') {
let query = kysely
.selectFrom('nostr_tags')
.select(({ fn }) => [
'nostr_tags.value',
fn.agg<number>('count', ['nostr_tags.pubkey']).distinct().as('authors'),
fn.countAll<number>().as('uses'),
])
.where('nostr_tags.name', 'in', tagNames)
.groupBy('nostr_tags.value')
.orderBy((c) => c.fn.agg('count', ['nostr_tags.pubkey']).distinct(), 'desc');
if (filter.kinds) {
query = query.where('nostr_tags.kind', 'in', filter.kinds);
}
if (typeof filter.since === 'number') {
query = query.where('nostr_tags.created_at', '>=', filter.since);
}
if (typeof filter.until === 'number') {
query = query.where('nostr_tags.created_at', '<=', filter.until);
}
if (typeof filter.limit === 'number') {
query = query.limit(filter.limit);
}
const rows = await query.execute();
return rows.map((row) => ({
value: row.value,
authors: Number(row.authors),
uses: Number(row.uses),
}));
}
return [];
} }
/** Get trending tags and publish an event with them. */ /** Get trending tags and publish an event with them. */

View file

@ -17,7 +17,7 @@ export class SimpleLRU<
constructor(fetchFn: FetchFn<K, V, { signal: AbortSignal }>, opts: LRUCache.Options<K, V, void>) { constructor(fetchFn: FetchFn<K, V, { signal: AbortSignal }>, opts: LRUCache.Options<K, V, void>) {
this.cache = new LRUCache({ this.cache = new LRUCache({
fetchMethod: (key, _staleValue, { signal }) => fetchFn(key, { signal: signal as AbortSignal }), fetchMethod: (key, _staleValue, { signal }) => fetchFn(key, { signal: signal as unknown as AbortSignal }),
...opts, ...opts,
}); });
} }

View file

@ -5,7 +5,6 @@ import { z } from 'zod';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
import { Conf } from '@/config.ts';
interface UpdateStatsOpts { interface UpdateStatsOpts {
kysely: Kysely<DittoTables>; kysely: Kysely<DittoTables>;
@ -197,16 +196,13 @@ export async function updateAuthorStats(
notes_count: 0, notes_count: 0,
}; };
let query = kysely const prev = await kysely
.selectFrom('author_stats') .selectFrom('author_stats')
.selectAll() .selectAll()
.where('pubkey', '=', pubkey); .forUpdate()
.where('pubkey', '=', pubkey)
.executeTakeFirst();
if (Conf.db.dialect === 'postgres') {
query = query.forUpdate();
}
const prev = await query.executeTakeFirst();
const stats = fn(prev ?? empty); const stats = fn(prev ?? empty);
if (prev) { if (prev) {
@ -249,16 +245,13 @@ export async function updateEventStats(
reactions: '{}', reactions: '{}',
}; };
let query = kysely const prev = await kysely
.selectFrom('event_stats') .selectFrom('event_stats')
.selectAll() .selectAll()
.where('event_id', '=', eventId); .forUpdate()
.where('event_id', '=', eventId)
.executeTakeFirst();
if (Conf.db.dialect === 'postgres') {
query = query.forUpdate();
}
const prev = await query.executeTakeFirst();
const stats = fn(prev ?? empty); const stats = fn(prev ?? empty);
if (prev) { if (prev) {

View file

@ -1,7 +1,7 @@
import Debug from '@soapbox/stickynotes/debug'; import Debug from '@soapbox/stickynotes/debug';
import * as Comlink from 'comlink'; import * as Comlink from 'comlink';
import './handlers/abortsignal.ts'; import '@/workers/handlers/abortsignal.ts';
import '@/sentry.ts'; import '@/sentry.ts';
const debug = Debug('ditto:fetch.worker'); const debug = Debug('ditto:fetch.worker');

View file

@ -1,52 +0,0 @@
import * as Comlink from 'comlink';
import { asyncGeneratorTransferHandler } from 'comlink-async-generator';
import { CompiledQuery, QueryResult } from 'kysely';
import type { SqliteWorker as _SqliteWorker } from './sqlite.worker.ts';
Comlink.transferHandlers.set('asyncGenerator', asyncGeneratorTransferHandler);
class SqliteWorker {
#worker: Worker;
#client: ReturnType<typeof Comlink.wrap<typeof _SqliteWorker>>;
#ready: Promise<void>;
constructor() {
this.#worker = new Worker(new URL('./sqlite.worker.ts', import.meta.url).href, { type: 'module' });
this.#client = Comlink.wrap<typeof _SqliteWorker>(this.#worker);
this.#ready = new Promise<void>((resolve) => {
const handleEvent = (event: MessageEvent) => {
if (event.data[0] === 'ready') {
this.#worker.removeEventListener('message', handleEvent);
resolve();
}
};
this.#worker.addEventListener('message', handleEvent);
});
}
async open(path: string): Promise<void> {
await this.#ready;
return this.#client.open(path);
}
async executeQuery<R>(query: CompiledQuery): Promise<QueryResult<R>> {
await this.#ready;
return this.#client.executeQuery(query) as Promise<QueryResult<R>>;
}
async *streamQuery<R>(query: CompiledQuery): AsyncIterableIterator<QueryResult<R>> {
await this.#ready;
for await (const result of await this.#client.streamQuery(query)) {
yield result as QueryResult<R>;
}
}
destroy(): Promise<void> {
return this.#client.destroy();
}
}
export default SqliteWorker;

View file

@ -1,42 +0,0 @@
/// <reference lib="webworker" />
import { Database as SQLite } from '@db/sqlite';
import * as Comlink from 'comlink';
import { CompiledQuery, QueryResult } from 'kysely';
import { asyncGeneratorTransferHandler } from 'comlink-async-generator';
import '@/sentry.ts';
let db: SQLite | undefined;
export const SqliteWorker = {
open(path: string): void {
db = new SQLite(path);
},
executeQuery<R>({ sql, parameters }: CompiledQuery): QueryResult<R> {
if (!db) throw new Error('Database not open');
return {
rows: db!.prepare(sql).all(...parameters as any[]) as R[],
numAffectedRows: BigInt(db!.changes),
insertId: BigInt(db!.lastInsertRowId),
};
},
async *streamQuery<R>({ sql, parameters }: CompiledQuery): AsyncIterableIterator<QueryResult<R>> {
if (!db) throw new Error('Database not open');
const stmt = db.prepare(sql).bind(...parameters as any[]);
for (const row of stmt) {
yield {
rows: [row],
};
}
},
destroy() {
db?.close();
},
};
Comlink.transferHandlers.set('asyncGenerator', asyncGeneratorTransferHandler);
Comlink.expose(SqliteWorker);
self.postMessage(['ready']);