mirror of
https://github.com/aljazceru/bakery.git
synced 2025-12-17 04:35:13 +01:00
use drizzle orm for database
This commit is contained in:
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@@ -19,6 +19,7 @@
|
||||
"args": ["--loader", "@swc-node/register/esm", "src/index.ts"],
|
||||
"outFiles": ["${workspaceFolder}/**/*.js"],
|
||||
"env": {
|
||||
"DATA_PATH": "./data",
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "bakery,bakery:*,applesauce,applesauce:*",
|
||||
"DEBUG_HIDE_DATE": "true",
|
||||
|
||||
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE!,
|
||||
},
|
||||
});
|
||||
44
drizzle/0000_fluffy_risque.sql
Normal file
44
drizzle/0000_fluffy_risque.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE `application_state` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`state` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `decryption_cache` (
|
||||
`event` text(64) PRIMARY KEY NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
FOREIGN KEY (`event`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `events` (
|
||||
`id` text(64) PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`pubkey` text(64) NOT NULL,
|
||||
`sig` text NOT NULL,
|
||||
`kind` integer NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`tags` text NOT NULL,
|
||||
`identifier` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `created_at` ON `events` (`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `pubkey` ON `events` (`pubkey`);--> statement-breakpoint
|
||||
CREATE INDEX `kind` ON `events` (`kind`);--> statement-breakpoint
|
||||
CREATE INDEX `identifier` ON `events` (`identifier`);--> statement-breakpoint
|
||||
CREATE TABLE `logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`timestamp` integer,
|
||||
`service` text NOT NULL,
|
||||
`message` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `tags` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`event` text(64) NOT NULL,
|
||||
`tag` text(1) NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
FOREIGN KEY (`event`) REFERENCES `events`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `event` ON `tags` (`event`);--> statement-breakpoint
|
||||
CREATE INDEX `tag` ON `tags` (`tag`);--> statement-breakpoint
|
||||
CREATE INDEX `value` ON `tags` (`value`);
|
||||
287
drizzle/meta/0000_snapshot.json
Normal file
287
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,287 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "118ac536-0bb2-4d0c-8bbe-1ba319ec7dc8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"application_state": {
|
||||
"name": "application_state",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"decryption_cache": {
|
||||
"name": "decryption_cache",
|
||||
"columns": {
|
||||
"event": {
|
||||
"name": "event",
|
||||
"type": "text(64)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"decryption_cache_event_events_id_fk": {
|
||||
"name": "decryption_cache_event_events_id_fk",
|
||||
"tableFrom": "decryption_cache",
|
||||
"tableTo": "events",
|
||||
"columnsFrom": [
|
||||
"event"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"events": {
|
||||
"name": "events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text(64)",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pubkey": {
|
||||
"name": "pubkey",
|
||||
"type": "text(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sig": {
|
||||
"name": "sig",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"columns": [
|
||||
"created_at"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"pubkey": {
|
||||
"name": "pubkey",
|
||||
"columns": [
|
||||
"pubkey"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"columns": [
|
||||
"kind"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"columns": [
|
||||
"identifier"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"logs": {
|
||||
"name": "logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "timestamp",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"service": {
|
||||
"name": "service",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tags": {
|
||||
"name": "tags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"event": {
|
||||
"name": "event",
|
||||
"type": "text(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"type": "text(1)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"event": {
|
||||
"name": "event",
|
||||
"columns": [
|
||||
"event"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"tag": {
|
||||
"name": "tag",
|
||||
"columns": [
|
||||
"tag"
|
||||
],
|
||||
"isUnique": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"columns": [
|
||||
"value"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tags_event_events_id_fk": {
|
||||
"name": "tags_event_events_id_fk",
|
||||
"tableFrom": "tags",
|
||||
"tableTo": "events",
|
||||
"columnsFrom": [
|
||||
"event"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1743251841984,
|
||||
"tag": "0000_fluffy_risque",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,12 +7,13 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"prepack": "tsc",
|
||||
"prepack": "pnpm build",
|
||||
"start": "node .",
|
||||
"dev": "DATA_PATH=./data nodemon --loader @swc-node/register/esm src/index.ts",
|
||||
"mcp": "mcp-inspector node . --mcp",
|
||||
"mcp-debug": "mcp-inspector node --inspect-brk . --mcp",
|
||||
"build": "tsc",
|
||||
"generate": "drizzle-kit generate",
|
||||
"test": "vitest run",
|
||||
"format": "prettier -w .",
|
||||
"prerelease-next": "pnpm build",
|
||||
@@ -45,6 +46,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"debug": "^4.4.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"express": "^4.21.2",
|
||||
"get-port": "^7.1.0",
|
||||
"hash-sum": "^2.0.0",
|
||||
@@ -84,8 +86,10 @@
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "^8.18.0",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"nodemon": "^3.1.9",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
},
|
||||
|
||||
900
pnpm-lock.yaml
generated
900
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
import EventEmitter from "events";
|
||||
import Database, { type Database as SQLDatabase } from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { DATA_PATH } from "../env.js";
|
||||
|
||||
export type LocalDatabaseConfig = {
|
||||
directory: string;
|
||||
name: string;
|
||||
wal: boolean;
|
||||
};
|
||||
|
||||
export default class LocalDatabase extends EventEmitter {
|
||||
config: LocalDatabaseConfig;
|
||||
path: { main: string; shm: string; wal: string };
|
||||
|
||||
db: SQLDatabase;
|
||||
|
||||
constructor(config: Partial<LocalDatabaseConfig>) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
directory: DATA_PATH,
|
||||
name: "events",
|
||||
wal: true,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.path = {
|
||||
main: path.join(this.config.directory, `${this.config.name}.db`),
|
||||
shm: path.join(this.config.directory, `${this.config.name}.db-shm`),
|
||||
wal: path.join(this.config.directory, `${this.config.name}.db-wal`),
|
||||
};
|
||||
|
||||
// Detect architecture to pass the correct native sqlite module
|
||||
this.db = new Database(this.path.main);
|
||||
|
||||
if (this.config.wal) this.db.exec("PRAGMA journal_mode = WAL");
|
||||
}
|
||||
|
||||
hasTable(table: string) {
|
||||
const result = this.db
|
||||
.prepare<[string], { count: number }>(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name=?`)
|
||||
.get(table);
|
||||
return !!result && result.count > 0;
|
||||
}
|
||||
|
||||
// Delete all events in the database
|
||||
/** @deprecated this should not be used */
|
||||
clear() {
|
||||
this.db.transaction(() => {
|
||||
this.db.prepare(`DELETE FROM tags`).run();
|
||||
if (this.hasTable("event_labels")) this.db.prepare(`DELETE FROM event_labels`).run();
|
||||
this.db.prepare(`DELETE FROM events`).run();
|
||||
})();
|
||||
}
|
||||
|
||||
// Get number of events in the database
|
||||
/** @deprecated this should be moved to a report */
|
||||
count() {
|
||||
const result = this.db.prepare(`SELECT COUNT(*) AS events FROM events`).get() as { events: number };
|
||||
|
||||
return result.events;
|
||||
}
|
||||
|
||||
// Get total size of the database on disk
|
||||
size() {
|
||||
let sum;
|
||||
|
||||
try {
|
||||
const statMain = fs.statSync(this.path.main).size;
|
||||
const statShm = this.config.wal ? fs.statSync(this.path.shm).size : 0;
|
||||
const statWal = this.config.wal ? fs.statSync(this.path.wal).size : 0;
|
||||
|
||||
sum = statMain + statShm + statWal;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { filter } from "rxjs";
|
||||
import cors from "cors";
|
||||
|
||||
import { logger } from "../logger.js";
|
||||
import Database from "./database.js";
|
||||
|
||||
import { NIP_11_SOFTWARE_URL, SENSITIVE_KINDS } from "../const.js";
|
||||
import { OWNER_PUBKEY, BAKERY_PORT } from "../env.js";
|
||||
@@ -18,7 +17,6 @@ import ControlApi from "../modules/control/control-api.js";
|
||||
import ConfigActions from "../modules/control/config-actions.js";
|
||||
import ReceiverActions from "../modules/control/receiver-actions.js";
|
||||
import Receiver from "../modules/receiver/index.js";
|
||||
import DatabaseActions from "../modules/control/database-actions.js";
|
||||
import DirectMessageManager from "../modules/direct-message-manager.js";
|
||||
import DirectMessageActions from "../modules/control/dm-actions.js";
|
||||
import AddressBook from "../modules/address-book.js";
|
||||
@@ -33,18 +31,17 @@ import DecryptionCache from "../modules/decryption-cache/decryption-cache.js";
|
||||
import DecryptionCacheActions from "../modules/control/decryption-cache.js";
|
||||
import Scrapper from "../modules/scrapper/index.js";
|
||||
import LogsActions from "../modules/control/logs-actions.js";
|
||||
import ApplicationStateManager from "../modules/state/application-state-manager.js";
|
||||
import ApplicationStateManager from "../modules/application-state/manager.js";
|
||||
import ScrapperActions from "../modules/control/scrapper-actions.js";
|
||||
import InboundNetworkManager from "../modules/network/inbound/index.js";
|
||||
import OutboundNetworkManager from "../modules/network/outbound/index.js";
|
||||
import SecretsManager from "../modules/secrets-manager.js";
|
||||
import Switchboard from "../modules/switchboard/switchboard.js";
|
||||
import Gossip from "../modules/gossip.js";
|
||||
import database from "../services/database.js";
|
||||
import secrets from "../services/secrets.js";
|
||||
import bakeryConfig from "../services/config.js";
|
||||
import logStore from "../services/log-store.js";
|
||||
import stateManager from "../services/state.js";
|
||||
import stateManager from "../services/app-state.js";
|
||||
import eventCache from "../services/event-cache.js";
|
||||
import { inboundNetwork, outboundNetwork } from "../services/network.js";
|
||||
import { server } from "../services/server.js";
|
||||
@@ -54,7 +51,8 @@ import { getDMRecipient } from "../helpers/direct-messages.js";
|
||||
import { onConnection, onJSONMessage } from "../helpers/ws.js";
|
||||
import QueryManager from "../modules/queries/manager.js";
|
||||
import "../modules/queries/queries/index.js";
|
||||
import bakerySigner from "../services/bakery.js";
|
||||
import bakerySigner from "../services/bakery-signer.js";
|
||||
import db from "../db/index.js";
|
||||
|
||||
type EventMap = {
|
||||
listening: [];
|
||||
@@ -74,7 +72,7 @@ export default class App extends EventEmitter<EventMap> {
|
||||
inboundNetwork: InboundNetworkManager;
|
||||
outboundNetwork: OutboundNetworkManager;
|
||||
|
||||
database: Database;
|
||||
database: typeof db;
|
||||
eventStore: SQLiteEventStore;
|
||||
logStore: LogStore;
|
||||
relay: NostrRelay;
|
||||
@@ -130,7 +128,7 @@ export default class App extends EventEmitter<EventMap> {
|
||||
});
|
||||
|
||||
// Init sqlite database
|
||||
this.database = database;
|
||||
this.database = db;
|
||||
|
||||
// create log managers
|
||||
this.logStore = logStore;
|
||||
@@ -149,8 +147,7 @@ export default class App extends EventEmitter<EventMap> {
|
||||
this.eventStore = eventCache;
|
||||
|
||||
// setup decryption cache
|
||||
this.decryptionCache = new DecryptionCache(this.database.db);
|
||||
this.decryptionCache.setup();
|
||||
this.decryptionCache = new DecryptionCache(this.database);
|
||||
|
||||
// Setup managers user contacts and profiles
|
||||
this.addressBook = new AddressBook();
|
||||
@@ -192,7 +189,6 @@ export default class App extends EventEmitter<EventMap> {
|
||||
this.control.registerHandler(new ConfigActions(this));
|
||||
this.control.registerHandler(new ReceiverActions(this));
|
||||
this.control.registerHandler(new ScrapperActions(this));
|
||||
this.control.registerHandler(new DatabaseActions(this));
|
||||
this.control.registerHandler(new DirectMessageActions(this));
|
||||
this.control.registerHandler(new NotificationActions(this));
|
||||
this.control.registerHandler(new RemoteAuthActions(this));
|
||||
@@ -395,9 +391,7 @@ export default class App extends EventEmitter<EventMap> {
|
||||
this.config.write();
|
||||
this.scrapper.stop();
|
||||
this.receiver.stop();
|
||||
await this.state.saveAll();
|
||||
this.relay.stop();
|
||||
this.database.destroy();
|
||||
this.receiver.destroy();
|
||||
|
||||
await this.inboundNetwork.stop();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { EventEmitter } from "events";
|
||||
import { LowSync, SyncAdapter } from "lowdb";
|
||||
import { JSONFileSync } from "lowdb/node";
|
||||
@@ -17,6 +18,7 @@ export class ReactiveJsonFile<T extends object> extends EventEmitter<EventMap<T>
|
||||
adapter: SyncAdapter<T>;
|
||||
|
||||
data: T;
|
||||
data$: BehaviorSubject<T>;
|
||||
|
||||
constructor(path: string, defaultData: T) {
|
||||
super();
|
||||
@@ -25,6 +27,7 @@ export class ReactiveJsonFile<T extends object> extends EventEmitter<EventMap<T>
|
||||
this.db = new LowSync<T>(this.adapter, defaultData);
|
||||
|
||||
this.data = this.createProxy();
|
||||
this.data$ = new BehaviorSubject(this.db.data);
|
||||
}
|
||||
|
||||
private createProxy() {
|
||||
@@ -34,6 +37,7 @@ export class ReactiveJsonFile<T extends object> extends EventEmitter<EventMap<T>
|
||||
},
|
||||
set: (target, p, newValue, receiver) => {
|
||||
Reflect.set(target, p, newValue, receiver);
|
||||
this.data$.next(target as T);
|
||||
this.emit("changed", target as T, String(p), newValue);
|
||||
this.emit("updated", target as T);
|
||||
return true;
|
||||
@@ -43,12 +47,14 @@ export class ReactiveJsonFile<T extends object> extends EventEmitter<EventMap<T>
|
||||
|
||||
read() {
|
||||
this.db.read();
|
||||
this.data$.next(this.db.data);
|
||||
this.emit("loaded", this.db.data);
|
||||
this.emit("updated", this.db.data);
|
||||
this.createProxy();
|
||||
}
|
||||
write() {
|
||||
this.db.write();
|
||||
this.data$.next(this.db.data);
|
||||
this.emit("saved", this.db.data);
|
||||
}
|
||||
update(fn: (data: T) => unknown) {
|
||||
|
||||
22
src/db/database.ts
Normal file
22
src/db/database.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import { DATABASE } from "../env.js";
|
||||
import * as schema from "./schema.js";
|
||||
import { setupEventFts } from "./search/events.js";
|
||||
import { setupDecryptedFts } from "./search/decrypted.js";
|
||||
|
||||
const sqlite = new Database(DATABASE);
|
||||
const bakeryDatabase = drizzle(sqlite, { schema });
|
||||
|
||||
export type BakeryDatabase = typeof bakeryDatabase;
|
||||
|
||||
// Run migrations first
|
||||
migrate(bakeryDatabase, { migrationsFolder: "./drizzle" });
|
||||
|
||||
// Setup search tables after migrations
|
||||
setupEventFts(sqlite);
|
||||
setupDecryptedFts(sqlite);
|
||||
|
||||
export default bakeryDatabase;
|
||||
20
src/db/helpers.ts
Normal file
20
src/db/helpers.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { type Database } from "better-sqlite3";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export function hasTable(db: Database, table: string) {
|
||||
return db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(table);
|
||||
}
|
||||
|
||||
export function parseEventRow(row: typeof schema.events.$inferSelect): NostrEvent {
|
||||
return {
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
pubkey: row.pubkey,
|
||||
content: row.content,
|
||||
created_at: row.created_at,
|
||||
sig: row.sig,
|
||||
tags: JSON.parse(row.tags),
|
||||
};
|
||||
}
|
||||
3
src/db/index.ts
Normal file
3
src/db/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default, type BakeryDatabase } from "./database.js";
|
||||
export * as schema from "./schema.js";
|
||||
export * as helpers from "./helpers.js";
|
||||
270
src/db/queries.ts
Normal file
270
src/db/queries.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Filter } from "nostr-tools";
|
||||
import { eq, sql, desc, isNull, and } from "drizzle-orm";
|
||||
|
||||
import { mapParams } from "../helpers/sql.js";
|
||||
import database from "./database.js";
|
||||
import { schema } from "./index.js";
|
||||
|
||||
const isFilterKeyIndexableTag = (key: string) => {
|
||||
return key[0] === "#" && key.length === 2;
|
||||
};
|
||||
const isFilterKeyIndexableAndTag = (key: string) => {
|
||||
return key[0] === "&" && key.length === 2;
|
||||
};
|
||||
|
||||
export const eventQuery = database.query.events
|
||||
.findFirst({
|
||||
where: (events, { eq }) => eq(events.id, sql.placeholder("id")),
|
||||
})
|
||||
.prepare();
|
||||
|
||||
export const addressableQuery = database.query.events
|
||||
.findFirst({
|
||||
where: (events, { eq }) =>
|
||||
and(
|
||||
eq(events.kind, sql.placeholder("kind")),
|
||||
eq(events.pubkey, sql.placeholder("pubkey")),
|
||||
eq(events.identifier, sql.placeholder("identifier")),
|
||||
),
|
||||
orderBy: [desc(schema.events.created_at), desc(schema.events.id)],
|
||||
})
|
||||
.prepare();
|
||||
export const addressableHistoryQuery = database.query.events
|
||||
.findMany({
|
||||
where: (events, { eq }) =>
|
||||
and(
|
||||
eq(events.kind, sql.placeholder("kind")),
|
||||
eq(events.pubkey, sql.placeholder("pubkey")),
|
||||
eq(events.identifier, sql.placeholder("identifier")),
|
||||
),
|
||||
orderBy: [desc(schema.events.created_at), desc(schema.events.id)],
|
||||
})
|
||||
.prepare();
|
||||
|
||||
export const replaceableQuery = database.query.events
|
||||
.findFirst({
|
||||
where: (events, { eq, isNull }) =>
|
||||
and(
|
||||
eq(events.kind, sql.placeholder("kind")),
|
||||
eq(events.pubkey, sql.placeholder("pubkey")),
|
||||
isNull(events.identifier),
|
||||
),
|
||||
orderBy: [desc(schema.events.created_at), desc(schema.events.id)],
|
||||
})
|
||||
.prepare();
|
||||
export const replaceableHistoryQuery = database.query.events
|
||||
.findMany({
|
||||
where: (events, { eq, isNull }) =>
|
||||
and(
|
||||
eq(events.kind, sql.placeholder("kind")),
|
||||
eq(events.pubkey, sql.placeholder("pubkey")),
|
||||
isNull(events.identifier),
|
||||
),
|
||||
orderBy: [desc(schema.events.created_at), desc(schema.events.id)],
|
||||
})
|
||||
.prepare();
|
||||
|
||||
function buildConditionsForFilter(filter: Filter) {
|
||||
const joins: string[] = [];
|
||||
const conditions: string[] = [];
|
||||
const parameters: (string | number)[] = [];
|
||||
const groupBy: string[] = [];
|
||||
const having: string[] = [];
|
||||
|
||||
// get AND tag filters
|
||||
const andTagQueries = Object.keys(filter).filter(isFilterKeyIndexableAndTag);
|
||||
// get OR tag filters and remove any ones that appear in the AND
|
||||
const orTagQueries = Object.keys(filter)
|
||||
.filter(isFilterKeyIndexableTag)
|
||||
.filter((t) => !andTagQueries.includes(t));
|
||||
|
||||
if (orTagQueries.length > 0) {
|
||||
joins.push("INNER JOIN tags as or_tags ON events.id = or_tags.event");
|
||||
}
|
||||
if (andTagQueries.length > 0) {
|
||||
joins.push("INNER JOIN tags as and_tags ON events.id = and_tags.event");
|
||||
}
|
||||
if (filter.search) {
|
||||
joins.push("INNER JOIN events_fts ON events_fts.id = events.id");
|
||||
|
||||
conditions.push(`events_fts MATCH ?`);
|
||||
parameters.push('"' + filter.search.replace(/"/g, '""') + '"');
|
||||
}
|
||||
|
||||
if (typeof filter.since === "number") {
|
||||
conditions.push(`events.created_at >= ?`);
|
||||
parameters.push(filter.since);
|
||||
}
|
||||
|
||||
if (typeof filter.until === "number") {
|
||||
conditions.push(`events.created_at < ?`);
|
||||
parameters.push(filter.until);
|
||||
}
|
||||
|
||||
if (filter.ids) {
|
||||
conditions.push(`events.id IN ${mapParams(filter.ids)}`);
|
||||
parameters.push(...filter.ids);
|
||||
}
|
||||
|
||||
if (filter.kinds) {
|
||||
conditions.push(`events.kind IN ${mapParams(filter.kinds)}`);
|
||||
parameters.push(...filter.kinds);
|
||||
}
|
||||
|
||||
if (filter.authors) {
|
||||
conditions.push(`events.pubkey IN ${mapParams(filter.authors)}`);
|
||||
parameters.push(...filter.authors);
|
||||
}
|
||||
|
||||
// add AND tag filters
|
||||
for (const t of andTagQueries) {
|
||||
conditions.push(`and_tags.tag = ?`);
|
||||
parameters.push(t.slice(1));
|
||||
|
||||
// @ts-expect-error
|
||||
const v = filter[t] as string[];
|
||||
conditions.push(`and_tags.value IN ${mapParams(v)}`);
|
||||
parameters.push(...v);
|
||||
}
|
||||
|
||||
// add OR tag filters
|
||||
for (let t of orTagQueries) {
|
||||
conditions.push(`or_tags.tag = ?`);
|
||||
parameters.push(t.slice(1));
|
||||
|
||||
// @ts-expect-error
|
||||
const v = filter[t] as string[];
|
||||
conditions.push(`or_tags.value IN ${mapParams(v)}`);
|
||||
parameters.push(...v);
|
||||
}
|
||||
|
||||
// if there is an AND tag filter set GROUP BY so that HAVING can be used
|
||||
if (andTagQueries.length > 0) {
|
||||
groupBy.push("events.id");
|
||||
having.push("COUNT(and_tags.id) = ?");
|
||||
|
||||
// @ts-expect-error
|
||||
parameters.push(andTagQueries.reduce((t, k) => t + (filter[k] as string[]).length, 0));
|
||||
}
|
||||
|
||||
return { conditions, parameters, joins, groupBy, having };
|
||||
}
|
||||
|
||||
export function buildSQLQueryForFilters(filters: Filter[], select = "events.*") {
|
||||
let stmt = `SELECT ${select} FROM events `;
|
||||
|
||||
const orConditions: string[] = [];
|
||||
const parameters: any[] = [];
|
||||
const groupBy = new Set<string>();
|
||||
const having = new Set<string>();
|
||||
|
||||
let joins = new Set<string>();
|
||||
for (const filter of filters) {
|
||||
const parts = buildConditionsForFilter(filter);
|
||||
|
||||
if (parts.conditions.length > 0) {
|
||||
orConditions.push(`(${parts.conditions.join(" AND ")})`);
|
||||
parameters.push(...parts.parameters);
|
||||
|
||||
for (const join of parts.joins) joins.add(join);
|
||||
for (const group of parts.groupBy) groupBy.add(group);
|
||||
for (const have of parts.having) having.add(have);
|
||||
}
|
||||
}
|
||||
|
||||
stmt += Array.from(joins).join(" ");
|
||||
|
||||
if (orConditions.length > 0) {
|
||||
stmt += ` WHERE ${orConditions.join(" OR ")}`;
|
||||
}
|
||||
|
||||
if (groupBy.size > 0) {
|
||||
stmt += " GROUP BY " + Array.from(groupBy).join(",");
|
||||
}
|
||||
if (having.size > 0) {
|
||||
stmt += " HAVING " + Array.from(having).join(" AND ");
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
const order = filters.find((f) => f.order)?.order;
|
||||
if (filters.some((f) => f.search) && (order === "rank" || order === undefined)) {
|
||||
stmt = stmt + " ORDER BY rank";
|
||||
} else {
|
||||
stmt = stmt + " ORDER BY created_at DESC";
|
||||
}
|
||||
|
||||
let minLimit = Infinity;
|
||||
for (const filter of filters) {
|
||||
if (filter.limit) minLimit = Math.min(minLimit, filter.limit);
|
||||
}
|
||||
if (minLimit !== Infinity) {
|
||||
stmt += " LIMIT ?";
|
||||
parameters.push(minLimit);
|
||||
}
|
||||
|
||||
return { stmt, parameters };
|
||||
}
|
||||
|
||||
// New code using drizzle
|
||||
// function buildConditionsForFilter(filter: Filter) {
|
||||
// const conditions: (SQL | undefined)[] = [];
|
||||
|
||||
// // Handle tag filters
|
||||
// const andTagQueries = Object.keys(filter).filter(isFilterKeyIndexableAndTag);
|
||||
// const orTagQueries = Object.keys(filter)
|
||||
// .filter(isFilterKeyIndexableTag)
|
||||
// .filter((t) => !andTagQueries.includes(t));
|
||||
|
||||
// if (filter.since) conditions.push(gte(events.createdAt, filter.since));
|
||||
// if (filter.until) conditions.push(lt(events.createdAt, filter.until));
|
||||
|
||||
// if (filter.ids) conditions.push(inArray(events.id, filter.ids));
|
||||
// if (filter.kinds) conditions.push(inArray(events.kind, filter.kinds));
|
||||
// if (filter.authors) conditions.push(inArray(events.pubkey, filter.authors));
|
||||
|
||||
// // Add tag conditions
|
||||
// if (orTagQueries.length > 0) {
|
||||
// const orConditions = orTagQueries.map((t) => {
|
||||
// // @ts-expect-error
|
||||
// const values = filter[t] as string[];
|
||||
// return and(eq(tags.tagag, t.slice(1)), inArray(tags.valuealue, values));
|
||||
// });
|
||||
// conditions.push(or(...orConditions));
|
||||
// }
|
||||
|
||||
// if (andTagQueries.length > 0) {
|
||||
// andTagQueries.forEach((t) => {
|
||||
// // @ts-expect-error
|
||||
// const values = filter[t] as string[];
|
||||
// conditions.push(and(eq(tags.tagag, t.slice(1)), inArray(tags.valuealue, values)));
|
||||
// });
|
||||
// }
|
||||
|
||||
// return conditions;
|
||||
// }
|
||||
|
||||
// export function buildDrizzleQueryForFilters(filters: (Filter & { order?: "rank" | "createdAt" })[]) {
|
||||
// const filterConditions = filters.map((filter) => and(...buildConditionsForFilter(filter)));
|
||||
|
||||
// let baseQuery = bakeryDatabase.select().from(events).leftJoin(tags, eq(events.id, tags.event));
|
||||
|
||||
// if (filterConditions.length > 0) {
|
||||
// baseQuery = baseQuery.where(or(...filterConditions));
|
||||
// }
|
||||
|
||||
// // Handle ordering
|
||||
// const order = filters.find((f) => f.order)?.order;
|
||||
// if (filters.some((f) => f.search) && (!order || order === "rank")) {
|
||||
// baseQuery = baseQuery.orderBy(sql`rank`);
|
||||
// } else {
|
||||
// baseQuery = baseQuery.orderBy(desc(events.createdAt));
|
||||
// }
|
||||
|
||||
// // Handle limit
|
||||
// const minLimit = Math.min(...filters.map((f) => f.limit || Infinity));
|
||||
// if (minLimit !== Infinity) {
|
||||
// baseQuery = baseQuery.limit(minLimit);
|
||||
// }
|
||||
|
||||
// return baseQuery;
|
||||
// }
|
||||
58
src/db/schema.ts
Normal file
58
src/db/schema.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { int, sqliteTable, text, index } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Event store tables
|
||||
export const events = sqliteTable(
|
||||
"events",
|
||||
{
|
||||
id: text("id", { length: 64 }).notNull().primaryKey(),
|
||||
created_at: int("created_at").notNull(),
|
||||
pubkey: text("pubkey", { length: 64 }).notNull(),
|
||||
sig: text("sig").notNull(),
|
||||
kind: int("kind").notNull(),
|
||||
content: text("content").notNull(),
|
||||
tags: text("tags").notNull(),
|
||||
identifier: text("identifier"),
|
||||
},
|
||||
(table) => [
|
||||
index("created_at").on(table.created_at),
|
||||
index("pubkey").on(table.pubkey),
|
||||
index("kind").on(table.kind),
|
||||
index("identifier").on(table.identifier),
|
||||
],
|
||||
);
|
||||
|
||||
// Event tags table
|
||||
export const tags = sqliteTable(
|
||||
"tags",
|
||||
{
|
||||
id: int("id").primaryKey({ autoIncrement: true }),
|
||||
event: text("event", { length: 64 })
|
||||
.references(() => events.id)
|
||||
.notNull(),
|
||||
tag: text("tag", { length: 1 }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
},
|
||||
(table) => [index("event").on(table.event), index("tag").on(table.tag), index("value").on(table.value)],
|
||||
);
|
||||
|
||||
// Decryption cache tables
|
||||
export const decryptionCache = sqliteTable("decryption_cache", {
|
||||
event: text("event", { length: 64 })
|
||||
.references(() => events.id)
|
||||
.notNull()
|
||||
.primaryKey(),
|
||||
content: text("content").notNull(),
|
||||
});
|
||||
|
||||
// Log store tables
|
||||
export const logs = sqliteTable("logs", {
|
||||
id: text("id").primaryKey(),
|
||||
timestamp: int("timestamp"),
|
||||
service: text("service").notNull(),
|
||||
message: text("message").notNull(),
|
||||
});
|
||||
|
||||
export const applicationState = sqliteTable("application_state", {
|
||||
id: text("id").primaryKey().notNull(),
|
||||
state: text("state"),
|
||||
});
|
||||
96
src/db/search/decrypted.ts
Normal file
96
src/db/search/decrypted.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { type Database } from "better-sqlite3";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
|
||||
import { logger } from "../../logger.js";
|
||||
import { hasTable, parseEventRow } from "../helpers.js";
|
||||
import * as schema from "../schema.js";
|
||||
import { HiddenContentSymbol } from "applesauce-core/helpers";
|
||||
|
||||
const log = logger.extend("Database:Search:Decrypted");
|
||||
|
||||
export function setupDecryptedFts(database: Database) {
|
||||
// Skip if search table already exists
|
||||
if (hasTable(database, "decryption_cache_fts")) return;
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS decryption_cache_fts USING fts5(content, content='decryption_cache', tokenize='trigram')`,
|
||||
)
|
||||
.run();
|
||||
log(`Created decryption cache search table`);
|
||||
|
||||
// create triggers to sync table
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
CREATE TRIGGER IF NOT EXISTS decryption_cache_ai AFTER INSERT ON decryption_cache BEGIN
|
||||
INSERT INTO decryption_cache_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
||||
END;
|
||||
`,
|
||||
)
|
||||
.run();
|
||||
database
|
||||
.prepare(
|
||||
`
|
||||
CREATE TRIGGER IF NOT EXISTS decryption_cache_ad AFTER DELETE ON decryption_cache BEGIN
|
||||
INSERT INTO decryption_cache_ai(decryption_cache_ai, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
|
||||
END;
|
||||
`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// populate table
|
||||
const inserted = database
|
||||
.prepare(`INSERT INTO decryption_cache_fts (rowid, content) SELECT rowid, content FROM decryption_cache`)
|
||||
.run();
|
||||
|
||||
log(`Indexed ${inserted.changes} decrypted events in search table`);
|
||||
}
|
||||
|
||||
export function searchDecrypted(
|
||||
database: Database,
|
||||
search: string,
|
||||
filter?: { conversation?: [string, string]; order?: "rank" | "created_at" },
|
||||
): NostrEvent[] {
|
||||
const params: any[] = [];
|
||||
const andConditions: string[] = [];
|
||||
|
||||
let sql = `SELECT events.*, decryption_cache.content as plaintext FROM decryption_cache_fts
|
||||
INNER JOIN decryption_cache ON decryption_cache_fts.rowid = decryption_cache.rowid
|
||||
INNER JOIN events ON decryption_cache.event = events.id`;
|
||||
|
||||
andConditions.push("decryption_cache_fts MATCH ?");
|
||||
params.push(search);
|
||||
|
||||
// filter down by authors
|
||||
if (filter?.conversation) {
|
||||
sql += `\nINNER JOIN tags ON tag.e = events.id AND tags.t = 'p'`;
|
||||
andConditions.push(`(tags.v = ? AND events.pubkey = ?) OR (tags.v = ? AND events.pubkey = ?)`);
|
||||
params.push(...filter.conversation, ...Array.from(filter.conversation).reverse());
|
||||
}
|
||||
|
||||
if (andConditions.length > 0) {
|
||||
sql += ` WHERE ${andConditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
switch (filter?.order) {
|
||||
case "rank":
|
||||
sql += " ORDER BY rank";
|
||||
break;
|
||||
|
||||
case "created_at":
|
||||
default:
|
||||
sql += " ORDER BY events.created_at DESC";
|
||||
break;
|
||||
}
|
||||
|
||||
return database
|
||||
.prepare<any[], typeof schema.events.$inferSelect & { plaintext: string }>(sql)
|
||||
.all(...params)
|
||||
.map((row) => {
|
||||
// Create the event object and add the hidden content
|
||||
const event = parseEventRow(row);
|
||||
Reflect.set(event, HiddenContentSymbol, row.plaintext);
|
||||
return event;
|
||||
});
|
||||
}
|
||||
101
src/db/search/events.ts
Normal file
101
src/db/search/events.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { kinds, NostrEvent } from "nostr-tools";
|
||||
import { type Database } from "better-sqlite3";
|
||||
|
||||
import * as schema from "../schema.js";
|
||||
import { logger } from "../../logger.js";
|
||||
import { hasTable, parseEventRow } from "../helpers.js";
|
||||
import { mapParams } from "../../helpers/sql.js";
|
||||
|
||||
const log = logger.extend("Database:Search:Events");
|
||||
|
||||
const SEARCHABLE_TAGS = ["title", "description", "about", "summary", "alt"];
|
||||
const SEARCHABLE_KIND_BLACKLIST = [kinds.EncryptedDirectMessage];
|
||||
const SEARCHABLE_CONTENT_FORMATTERS: Record<number, (content: string) => string> = {
|
||||
[kinds.Metadata]: (content) => {
|
||||
const SEARCHABLE_PROFILE_FIELDS = [
|
||||
"name",
|
||||
"display_name",
|
||||
"about",
|
||||
"nip05",
|
||||
"lud16",
|
||||
"website",
|
||||
// Deprecated fields
|
||||
"displayName",
|
||||
"username",
|
||||
];
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
const json = JSON.parse(content);
|
||||
|
||||
for (const field of SEARCHABLE_PROFILE_FIELDS) {
|
||||
if (json[field]) lines.push(json[field]);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
} catch (error) {
|
||||
return content;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function convertEventToSearchRow(event: NostrEvent) {
|
||||
const tags = event.tags
|
||||
.filter((t) => SEARCHABLE_TAGS.includes(t[0]))
|
||||
.map((t) => t[1])
|
||||
.join(" ");
|
||||
|
||||
const content = SEARCHABLE_CONTENT_FORMATTERS[event.kind]
|
||||
? SEARCHABLE_CONTENT_FORMATTERS[event.kind](event.content)
|
||||
: event.content;
|
||||
|
||||
return { id: event.id, content, tags };
|
||||
}
|
||||
|
||||
export function setupEventFts(database: Database) {
|
||||
// Skip if search table already exists
|
||||
if (hasTable(database, "events_fts")) return;
|
||||
|
||||
database
|
||||
.prepare(
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(id UNINDEXED, content, tags, tokenize='trigram')`,
|
||||
)
|
||||
.run();
|
||||
|
||||
log("Created event search table");
|
||||
|
||||
const events = database
|
||||
.prepare<number[], typeof schema.events.$inferSelect>(
|
||||
`SELECT * FROM events WHERE kind NOT IN ${mapParams(SEARCHABLE_KIND_BLACKLIST)}`,
|
||||
)
|
||||
.all(...SEARCHABLE_KIND_BLACKLIST)
|
||||
.map(parseEventRow);
|
||||
|
||||
// insert search content into table
|
||||
let changes = 0;
|
||||
for (const event of events) {
|
||||
const search = convertEventToSearchRow(event);
|
||||
|
||||
// manually insert into fts table
|
||||
const result = database
|
||||
.prepare<[string, string, string]>(`INSERT OR REPLACE INTO events_fts (id, content, tags) VALUES (?, ?, ?)`)
|
||||
.run(search.id, search.content, search.tags);
|
||||
|
||||
changes += result.changes;
|
||||
}
|
||||
log(`Inserted ${changes} events into search table`);
|
||||
}
|
||||
|
||||
export function insertEventIntoSearch(database: Database, event: NostrEvent): boolean {
|
||||
const search = convertEventToSearchRow(event);
|
||||
|
||||
const result = database
|
||||
.prepare<[string, string, string]>(`INSERT OR REPLACE INTO events_fts (id, content, tags) VALUES (?, ?, ?)`)
|
||||
.run(search.id, search.content, search.tags);
|
||||
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function removeEventsFromSearch(database: Database, events: string[]): boolean {
|
||||
const result = database.prepare<string[]>(`DELETE FROM events_fts WHERE id IN ${mapParams(events)}`).run(...events);
|
||||
return result.changes > 0;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const PUBLIC_ADDRESS = process.env.PUBLIC_ADDRESS;
|
||||
export const DATA_PATH = process.env.DATA_PATH || join(homedir(), ".bakery");
|
||||
await mkdirp(DATA_PATH);
|
||||
|
||||
export const DATABASE = join(DATA_PATH, "bakery.db");
|
||||
|
||||
export const BAKERY_PORT = parseInt(args.values.port ?? process.env.BAKERY_PORT ?? "") || DEFAULT_PORT;
|
||||
|
||||
// I2P config
|
||||
|
||||
16
src/index.ts
16
src/index.ts
@@ -14,7 +14,9 @@ import App from "./app/index.js";
|
||||
import { PUBLIC_ADDRESS, IS_MCP } from "./env.js";
|
||||
import { addListener, logger } from "./logger.js";
|
||||
import { pathExists } from "./helpers/fs.js";
|
||||
import "./services/owner.js";
|
||||
import stateManager from "./services/app-state.js";
|
||||
import bakeryDatabase from "./db/database.js";
|
||||
import logStore from "./services/log-store.js";
|
||||
|
||||
// add durations plugin
|
||||
dayjs.extend(duration);
|
||||
@@ -81,9 +83,19 @@ if (IS_MCP) {
|
||||
|
||||
// shutdown process
|
||||
async function shutdown() {
|
||||
logger("shutting down");
|
||||
logger("Shutting down...");
|
||||
|
||||
// Stop the app
|
||||
await app.stop();
|
||||
|
||||
// Save the application state
|
||||
stateManager.saveAll();
|
||||
|
||||
// Stop writing the logs to the database
|
||||
logStore.close();
|
||||
|
||||
// Close the database last
|
||||
bakeryDatabase.$client.close();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
94
src/modules/application-state/manager.ts
Normal file
94
src/modules/application-state/manager.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { BehaviorSubject, Subject, tap, throttleTime } from "rxjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { BakeryDatabase } from "../../db/database.js";
|
||||
import { schema } from "../../db/index.js";
|
||||
import { logger } from "../../logger.js";
|
||||
|
||||
function createMutableState<T extends object>(
|
||||
database: BakeryDatabase,
|
||||
key: string,
|
||||
initialState: T,
|
||||
throttle = 1000,
|
||||
): T {
|
||||
const existing = database.select().from(schema.applicationState).where(eq(schema.applicationState.id, key)).get();
|
||||
|
||||
// Use json.parse to create a new object
|
||||
const state = JSON.parse(existing?.state || JSON.stringify(initialState)) as T;
|
||||
|
||||
// Save the state if it doesn't exist
|
||||
if (!existing)
|
||||
database
|
||||
.insert(schema.applicationState)
|
||||
.values({ id: key, state: JSON.stringify(state) })
|
||||
.run();
|
||||
|
||||
const dirty = new BehaviorSubject(false);
|
||||
const save = new Subject<T>();
|
||||
|
||||
// only save the state every x ms
|
||||
save
|
||||
.pipe(
|
||||
tap(() => dirty.value === false && dirty.next(true)),
|
||||
throttleTime(throttle),
|
||||
)
|
||||
.subscribe((state) => {
|
||||
database
|
||||
.update(schema.applicationState)
|
||||
.set({ state: JSON.stringify(state) })
|
||||
.where(eq(schema.applicationState.id, key))
|
||||
.run();
|
||||
|
||||
dirty.next(false);
|
||||
});
|
||||
|
||||
return new Proxy(state, {
|
||||
get(target, prop, receiver) {
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set(target, prop, value, receiver) {
|
||||
Reflect.set(target, prop, value, receiver);
|
||||
save.next(target);
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, prop) {
|
||||
Reflect.deleteProperty(target, prop);
|
||||
save.next(target);
|
||||
return true;
|
||||
},
|
||||
ownKeys(target) {
|
||||
return Reflect.ownKeys(target);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default class ApplicationStateManager {
|
||||
protected log = logger.extend("State");
|
||||
|
||||
protected mutableState = new Map<string, any>();
|
||||
constructor(public database: BakeryDatabase) {}
|
||||
|
||||
getMutableState<T extends object>(key: string, initialState: T): T {
|
||||
const existing = this.mutableState.get(key);
|
||||
if (existing) return existing as T;
|
||||
|
||||
this.log(`Loading state for ${key}`);
|
||||
const state = createMutableState(this.database, key, initialState);
|
||||
this.mutableState.set(key, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
saveAll() {
|
||||
this.log("Saving all application states");
|
||||
for (const [key, state] of this.mutableState.entries()) {
|
||||
this.database
|
||||
.update(schema.applicationState)
|
||||
.set({ state: JSON.stringify(state) })
|
||||
.where(eq(schema.applicationState.id, key))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { DatabaseMessage, DatabaseResponse, DatabaseStats } from "@satellite-earth/core/types/control-api/database.js";
|
||||
|
||||
import App from "../../app/index.js";
|
||||
import { ControlMessageHandler } from "./control-api.js";
|
||||
|
||||
export default class DatabaseActions implements ControlMessageHandler {
|
||||
app: App;
|
||||
name = "DATABASE";
|
||||
|
||||
subscribed = new Set<WebSocket | NodeJS.Process>();
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
|
||||
// update all subscribed sockets every 5 seconds
|
||||
let last: DatabaseStats | undefined = undefined;
|
||||
setInterval(() => {
|
||||
const stats = this.getStats();
|
||||
if (stats.count !== last?.count || stats.size !== last.size) {
|
||||
for (const sock of this.subscribed) {
|
||||
this.send(sock, ["CONTROL", "DATABASE", "STATS", stats]);
|
||||
}
|
||||
}
|
||||
last = stats;
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
private getStats() {
|
||||
const count = this.app.database.count();
|
||||
const size = this.app.database.size();
|
||||
|
||||
return { count, size };
|
||||
}
|
||||
|
||||
handleMessage(sock: WebSocket | NodeJS.Process, message: DatabaseMessage): boolean {
|
||||
const action = message[2];
|
||||
switch (action) {
|
||||
case "SUBSCRIBE":
|
||||
this.subscribed.add(sock);
|
||||
sock.once("close", () => this.subscribed.delete(sock));
|
||||
this.send(sock, ["CONTROL", "DATABASE", "STATS", this.getStats()]);
|
||||
return true;
|
||||
|
||||
case "UNSUBSCRIBE":
|
||||
this.subscribed.delete(sock);
|
||||
return true;
|
||||
|
||||
case "STATS":
|
||||
this.send(sock, ["CONTROL", "DATABASE", "STATS", this.getStats()]);
|
||||
return true;
|
||||
|
||||
case "CLEAR":
|
||||
this.app.database.clear();
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
send(sock: WebSocket | NodeJS.Process, response: DatabaseResponse) {
|
||||
sock.send?.(JSON.stringify(response));
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,15 @@ export default class DecryptionCacheActions implements ControlMessageHandler {
|
||||
this.app.decryptionCache.addEventContent(message[3], message[4]);
|
||||
return true;
|
||||
|
||||
case "CLEAR-PUBKEY":
|
||||
this.app.decryptionCache.clearPubkey(message[3]);
|
||||
return true;
|
||||
|
||||
case "CLEAR":
|
||||
this.app.decryptionCache.clearAll();
|
||||
return true;
|
||||
|
||||
case "REQUEST":
|
||||
this.app.decryptionCache.getEventsContent(message[3]).then((contents) => {
|
||||
const contents = this.app.decryptionCache.getEventsContent(message[3]);
|
||||
for (const { event, content } of contents)
|
||||
this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "CONTENT", event, content]);
|
||||
this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "END"]);
|
||||
});
|
||||
return true;
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,153 +1,50 @@
|
||||
import { MigrationSet } from "../../sqlite/migrations.js";
|
||||
import { type Database } from "better-sqlite3";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { getHiddenContent } from "applesauce-core/helpers";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { EventRow, parseEventRow } from "../../sqlite/event-store.js";
|
||||
import { logger } from "../../logger.js";
|
||||
import { NostrEvent } from "nostr-tools";
|
||||
import { mapParams } from "../../helpers/sql.js";
|
||||
|
||||
const migrations = new MigrationSet("decryption-cache");
|
||||
|
||||
// Version 1
|
||||
migrations.addScript(1, async (db, log) => {
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE "decryption_cache" (
|
||||
"event" TEXT(64) NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
PRIMARY KEY("event")
|
||||
);
|
||||
`,
|
||||
).run();
|
||||
});
|
||||
|
||||
// Version 2, search
|
||||
migrations.addScript(2, async (db, log) => {
|
||||
// create external Content fts5 table
|
||||
db.prepare(
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS decryption_cache_fts USING fts5(content, content='decryption_cache', tokenize='trigram')`,
|
||||
).run();
|
||||
log(`Created decryption cache search table`);
|
||||
|
||||
// create triggers to sync table
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TRIGGER IF NOT EXISTS decryption_cache_ai AFTER INSERT ON decryption_cache BEGIN
|
||||
INSERT INTO decryption_cache_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
||||
END;
|
||||
`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TRIGGER IF NOT EXISTS decryption_cache_ad AFTER DELETE ON decryption_cache BEGIN
|
||||
INSERT INTO decryption_cache_ai(decryption_cache_ai, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
|
||||
END;
|
||||
`,
|
||||
).run();
|
||||
|
||||
// populate table
|
||||
const inserted = db
|
||||
.prepare(`INSERT INTO decryption_cache_fts (rowid, content) SELECT rowid, content FROM decryption_cache`)
|
||||
.run();
|
||||
log(`Indexed ${inserted.changes} decrypted events in search table`);
|
||||
});
|
||||
import { schema, type BakeryDatabase } from "../../db/index.js";
|
||||
import { searchDecrypted } from "../../db/search/decrypted.js";
|
||||
|
||||
type EventMap = {
|
||||
cache: [string, string];
|
||||
};
|
||||
|
||||
export default class DecryptionCache extends EventEmitter<EventMap> {
|
||||
database: Database;
|
||||
log = logger.extend("DecryptionCache");
|
||||
|
||||
constructor(database: Database) {
|
||||
constructor(public database: BakeryDatabase) {
|
||||
super();
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
setup() {
|
||||
return migrations.run(this.database);
|
||||
}
|
||||
|
||||
/** cache the decrypted content of an event */
|
||||
addEventContent(id: string, plaintext: string) {
|
||||
const result = this.database
|
||||
.prepare<[string, string]>(`INSERT INTO decryption_cache (event, content) VALUES (?, ?)`)
|
||||
.run(id, plaintext);
|
||||
addEventContent(event: string, plaintext: string) {
|
||||
const result = this.database.insert(schema.decryptionCache).values({ event: event, content: plaintext }).run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
this.log(`Saved content for ${id}`);
|
||||
|
||||
this.emit("cache", id, plaintext);
|
||||
this.log(`Saved content for ${event}`);
|
||||
this.emit("cache", event, plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
/** remove all cached content relating to a pubkey */
|
||||
clearPubkey(pubkey: string) {
|
||||
// this.database.prepare<string>(`DELETE FROM decryption_cache INNER JOIN events ON event=events.id`)
|
||||
search(query: string, filter?: { conversation?: [string, string]; order?: "rank" | "created_at" }) {
|
||||
return searchDecrypted(this.database.$client, query, filter).map((event) => ({
|
||||
event,
|
||||
plaintext: getHiddenContent(event)!,
|
||||
}));
|
||||
}
|
||||
|
||||
/** clear all cached content */
|
||||
clearAll() {
|
||||
this.database.prepare(`DELETE FROM decryption_cache`).run();
|
||||
this.database.delete(schema.decryptionCache).run();
|
||||
}
|
||||
|
||||
async search(
|
||||
search: string,
|
||||
filter?: { conversation?: [string, string]; order?: "rank" | "created_at" },
|
||||
): Promise<{ event: NostrEvent; plaintext: string }[]> {
|
||||
const params: any[] = [];
|
||||
const andConditions: string[] = [];
|
||||
|
||||
let sql = `SELECT events.*, decryption_cache.content as plaintext FROM decryption_cache_fts
|
||||
INNER JOIN decryption_cache ON decryption_cache_fts.rowid = decryption_cache.rowid
|
||||
INNER JOIN events ON decryption_cache.event = events.id`;
|
||||
|
||||
andConditions.push("decryption_cache_fts MATCH ?");
|
||||
params.push(search);
|
||||
|
||||
// filter down by authors
|
||||
if (filter?.conversation) {
|
||||
sql += `\nINNER JOIN tags ON tag.e = events.id AND tags.t = 'p'`;
|
||||
andConditions.push(`(tags.v = ? AND events.pubkey = ?) OR (tags.v = ? AND events.pubkey = ?)`);
|
||||
params.push(...filter.conversation, ...Array.from(filter.conversation).reverse());
|
||||
getEventContent(id: string): string | undefined {
|
||||
return this.database.select().from(schema.decryptionCache).where(eq(schema.decryptionCache.event, id)).get()
|
||||
?.content;
|
||||
}
|
||||
|
||||
if (andConditions.length > 0) {
|
||||
sql += ` WHERE ${andConditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
switch (filter?.order) {
|
||||
case "rank":
|
||||
sql += " ORDER BY rank";
|
||||
break;
|
||||
|
||||
case "created_at":
|
||||
default:
|
||||
sql += " ORDER BY events.created_at DESC";
|
||||
break;
|
||||
}
|
||||
|
||||
return this.database
|
||||
.prepare<any[], EventRow & { plaintext: string }>(sql)
|
||||
.all(...params)
|
||||
.map((row) => ({ event: parseEventRow(row), plaintext: row.plaintext }));
|
||||
}
|
||||
|
||||
async getEventContent(id: string) {
|
||||
const result = this.database
|
||||
.prepare<[string], { event: string; content: string }>(`SELECT * FROM decryption_cache WHERE event=?`)
|
||||
.get(id);
|
||||
|
||||
return result?.content;
|
||||
}
|
||||
async getEventsContent(ids: string[]) {
|
||||
return this.database
|
||||
.prepare<
|
||||
string[],
|
||||
{ event: string; content: string }
|
||||
>(`SELECT * FROM decryption_cache WHERE event IN ${mapParams(ids)}`)
|
||||
.all(...ids);
|
||||
getEventsContent(ids: string[]): (typeof schema.decryptionCache.$inferSelect)[] {
|
||||
return this.database.select().from(schema.decryptionCache).where(inArray(schema.decryptionCache.event, ids)).all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,32 @@
|
||||
import { type Database as SQLDatabase } from "better-sqlite3";
|
||||
import EventEmitter from "events";
|
||||
import { bufferTime, filter, firstValueFrom, Subject, Subscription } from "rxjs";
|
||||
import { gte, lte, like, and } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { Debugger } from "debug";
|
||||
|
||||
import { logger } from "../../logger.js";
|
||||
import { MigrationSet } from "../../sqlite/migrations.js";
|
||||
import { BakeryDatabase } from "../../db/database.js";
|
||||
import { schema } from "../../db/index.js";
|
||||
|
||||
type EventMap = {
|
||||
log: [LogEntry];
|
||||
clear: [string | undefined];
|
||||
export type LogFilter = {
|
||||
service?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
};
|
||||
|
||||
export type LogEntry = {
|
||||
id: string;
|
||||
service: string;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
};
|
||||
export type DatabaseLogEntry = LogEntry & {
|
||||
id: number | bigint;
|
||||
};
|
||||
export default class LogStore {
|
||||
public insert$ = new Subject<typeof schema.logs.$inferInsert>();
|
||||
protected write$ = new Subject<typeof schema.logs.$inferInsert>();
|
||||
|
||||
const migrations = new MigrationSet("log-store");
|
||||
protected writeQueue: Subscription;
|
||||
|
||||
// version 1
|
||||
migrations.addScript(1, async (db, log) => {
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS "logs" (
|
||||
"id" TEXT NOT NULL UNIQUE,
|
||||
"timestamp" INTEGER NOT NULL,
|
||||
"service" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
`,
|
||||
).run();
|
||||
log("Created logs table");
|
||||
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS logs_service ON logs(service)");
|
||||
log("Created logs service index");
|
||||
});
|
||||
|
||||
export default class LogStore extends EventEmitter<EventMap> {
|
||||
database: SQLDatabase;
|
||||
debug: Debugger;
|
||||
|
||||
constructor(database: SQLDatabase) {
|
||||
super();
|
||||
this.database = database;
|
||||
this.debug = logger;
|
||||
}
|
||||
|
||||
async setup() {
|
||||
return await migrations.run(this.database);
|
||||
constructor(public database: BakeryDatabase) {
|
||||
// Buffer writes to the database
|
||||
this.writeQueue = this.write$
|
||||
.pipe(
|
||||
bufferTime(1000, null, 5000),
|
||||
filter((entries) => entries.length > 0),
|
||||
)
|
||||
.subscribe((entries) => {
|
||||
this.database.insert(schema.logs).values(entries).run();
|
||||
});
|
||||
}
|
||||
|
||||
addEntry(service: string, timestamp: Date | number, message: string) {
|
||||
@@ -65,99 +38,37 @@ export default class LogStore extends EventEmitter<EventMap> {
|
||||
message,
|
||||
};
|
||||
|
||||
this.queue.push(entry);
|
||||
this.emit("log", entry);
|
||||
|
||||
if (!this.running) this.write();
|
||||
this.write$.next(entry);
|
||||
this.insert$.next(entry);
|
||||
}
|
||||
|
||||
running = false;
|
||||
queue: LogEntry[] = [];
|
||||
private write() {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
|
||||
const BATCH_SIZE = 5000;
|
||||
|
||||
const inserted: (number | bigint)[] = [];
|
||||
const failed: LogEntry[] = [];
|
||||
|
||||
this.database.transaction(() => {
|
||||
let i = 0;
|
||||
while (this.queue.length) {
|
||||
const entry = this.queue.shift()!;
|
||||
try {
|
||||
const { lastInsertRowid } = this.database
|
||||
.prepare<
|
||||
[string, string, number, string]
|
||||
>(`INSERT INTO "logs" (id, service, timestamp, message) VALUES (?, ?, ?, ?)`)
|
||||
.run(entry.id, entry.service, entry.timestamp, entry.message);
|
||||
|
||||
inserted.push(lastInsertRowid);
|
||||
} catch (error) {
|
||||
failed.push(entry);
|
||||
getLogs(filter?: LogFilter & { limit?: number }) {
|
||||
return this.database
|
||||
.select()
|
||||
.from(schema.logs)
|
||||
.where(({ service, timestamp }) => {
|
||||
const conditions = [];
|
||||
if (filter?.service) conditions.push(like(service, `${filter.service}%`));
|
||||
if (filter?.since) conditions.push(gte(timestamp, filter.since));
|
||||
if (filter?.until) conditions.push(lte(timestamp, filter.until));
|
||||
return and(...conditions);
|
||||
})
|
||||
.limit(filter?.limit ?? -1)
|
||||
.all();
|
||||
}
|
||||
|
||||
if (++i >= BATCH_SIZE) break;
|
||||
}
|
||||
})();
|
||||
clearLogs(filter?: LogFilter) {
|
||||
const conditions = [];
|
||||
if (filter?.service) conditions.push(like(schema.logs.service, `${filter.service}%`));
|
||||
if (filter?.since) conditions.push(gte(schema.logs.timestamp, filter.since));
|
||||
if (filter?.until) conditions.push(lte(schema.logs.timestamp, filter.until));
|
||||
const where = and(...conditions);
|
||||
|
||||
for (const entry of failed) {
|
||||
// Don't know what to do here...
|
||||
this.database.delete(schema.logs).where(where).run();
|
||||
}
|
||||
|
||||
if (this.queue.length > 0) setTimeout(this.write.bind(this), 1000);
|
||||
else this.running = false;
|
||||
}
|
||||
|
||||
getLogs(filter?: { service?: string; since?: number; until?: number; limit?: number }) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: (string | number)[] = [];
|
||||
|
||||
let sql = `SELECT * FROM logs`;
|
||||
|
||||
if (filter?.service) {
|
||||
conditions.push(`service LIKE CONCAT(?,'%')`);
|
||||
parameters.push(filter?.service);
|
||||
}
|
||||
if (filter?.since) {
|
||||
conditions.push("timestamp>=?");
|
||||
parameters.push(filter?.since);
|
||||
}
|
||||
if (filter?.until) {
|
||||
conditions.push("timestamp<=?");
|
||||
parameters.push(filter?.until);
|
||||
}
|
||||
if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
if (filter?.limit) {
|
||||
sql += " LIMIT ?";
|
||||
parameters.push(filter.limit);
|
||||
}
|
||||
return this.database.prepare<any[], DatabaseLogEntry>(sql).all(...parameters);
|
||||
}
|
||||
|
||||
clearLogs(filter?: { service?: string; since?: number; until?: number }) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: (string | number)[] = [];
|
||||
|
||||
let sql = `DELETE FROM logs`;
|
||||
|
||||
if (filter?.service) {
|
||||
conditions.push("service=?");
|
||||
parameters.push(filter?.service);
|
||||
}
|
||||
if (filter?.since) {
|
||||
conditions.push("timestamp>=?");
|
||||
parameters.push(filter?.since);
|
||||
}
|
||||
if (filter?.until) {
|
||||
conditions.push("timestamp<=?");
|
||||
parameters.push(filter?.until);
|
||||
}
|
||||
if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
|
||||
|
||||
this.database.prepare<any[]>(sql).run(parameters);
|
||||
this.emit("clear", filter?.service);
|
||||
close() {
|
||||
// stop writing to the database
|
||||
this.writeQueue.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import webPush from "web-push";
|
||||
|
||||
import { logger } from "../../logger.js";
|
||||
import App from "../../app/index.js";
|
||||
import stateManager from "../../services/state.js";
|
||||
import stateManager from "../../services/app-state.js";
|
||||
import bakeryConfig from "../../services/config.js";
|
||||
import { getDMRecipient, getDMSender } from "../../helpers/direct-messages.js";
|
||||
|
||||
@@ -40,9 +40,9 @@ export default class NotificationsManager extends EventEmitter<EventMap> {
|
||||
}
|
||||
|
||||
async setup() {
|
||||
this.state = (
|
||||
await stateManager.getMutableState<NotificationsManagerState>("notification-manager", { channels: [] })
|
||||
).proxy;
|
||||
this.state = stateManager.getMutableState<NotificationsManagerState>("notification-manager", {
|
||||
channels: [],
|
||||
});
|
||||
}
|
||||
|
||||
addOrUpdateChannel(channel: NotificationChannel) {
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Query } from "../types.js";
|
||||
import bakeryConfig, { BakeryConfig } from "../../../services/config.js";
|
||||
|
||||
export const ConfigQuery: Query<BakeryConfig> = () =>
|
||||
new Observable((observer) => {
|
||||
observer.next(bakeryConfig.data);
|
||||
const listener = (c: BakeryConfig) => observer.next(c);
|
||||
bakeryConfig.on("updated", listener);
|
||||
return () => bakeryConfig.off("updated", listener);
|
||||
});
|
||||
export const ConfigQuery: Query<BakeryConfig> = () => bakeryConfig.data$;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { filter, from, fromEvent, merge } from "rxjs";
|
||||
import { filter, from, merge } from "rxjs";
|
||||
|
||||
import { Query } from "../types.js";
|
||||
import logStore from "../../../services/log-store.js";
|
||||
import { LogEntry } from "../../log-store/log-store.js";
|
||||
import { schema } from "../../../db/index.js";
|
||||
|
||||
export const LogsQuery: Query<LogEntry> = (args: {
|
||||
export const LogsQuery: Query<typeof schema.logs.$inferSelect> = (args: {
|
||||
service?: string;
|
||||
since?: number;
|
||||
until?: number;
|
||||
@@ -13,7 +14,7 @@ export const LogsQuery: Query<LogEntry> = (args: {
|
||||
// get last 500 lines
|
||||
from(logStore.getLogs({ service: args.service, limit: 500 })),
|
||||
// subscribe to new logs
|
||||
fromEvent<LogEntry>(logStore, "log").pipe(
|
||||
logStore.insert$.pipe(
|
||||
// only return logs that match args
|
||||
filter((entry) => {
|
||||
return !args?.service || entry.service === args.service;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { from, merge, NEVER } from "rxjs";
|
||||
import database from "../../../services/database.js";
|
||||
import { Query } from "../types.js";
|
||||
import bakeryDatabase, { schema } from "../../../db/index.js";
|
||||
|
||||
export const ServicesQuery: Query<string[]> = () =>
|
||||
merge(NEVER, from(database.db.prepare<[], { id: string }>(`SELECT service as id FROM logs GROUP BY service`).all()));
|
||||
merge(
|
||||
NEVER,
|
||||
from(
|
||||
bakeryDatabase
|
||||
.select()
|
||||
.from(schema.logs)
|
||||
.groupBy(schema.logs.service)
|
||||
.all()
|
||||
.map((row) => row.service),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class Scrapper extends EventEmitter<EventMap> {
|
||||
}
|
||||
|
||||
async setup() {
|
||||
this.state = (await this.app.state.getMutableState<ScrapperState>("scrapper", { pubkeys: [] })).proxy;
|
||||
this.state = this.app.state.getMutableState<ScrapperState>("scrapper", { pubkeys: [] });
|
||||
}
|
||||
|
||||
async ensureData() {
|
||||
|
||||
@@ -56,7 +56,7 @@ export default class PubkeyScrapper extends EventEmitter<EventMap> {
|
||||
`${this.pubkey}|${relay.url}`,
|
||||
{},
|
||||
);
|
||||
if (state) scrapper.state = state.proxy;
|
||||
if (state) scrapper.state = state;
|
||||
|
||||
this.relayScrappers.set(url, scrapper);
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Database } from "better-sqlite3";
|
||||
|
||||
import { MutableState } from "./mutable-state.js";
|
||||
import { MigrationSet } from "../../sqlite/migrations.js";
|
||||
|
||||
const migrations = new MigrationSet("application-state");
|
||||
|
||||
migrations.addScript(1, async (db, log) => {
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE "application_state" (
|
||||
"id" TEXT NOT NULL,
|
||||
"state" TEXT,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
`,
|
||||
).run();
|
||||
|
||||
log("Created application state table");
|
||||
});
|
||||
|
||||
export default class ApplicationStateManager {
|
||||
private mutableState = new Map<string, MutableState<any>>();
|
||||
|
||||
database: Database;
|
||||
constructor(database: Database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
async setup() {
|
||||
await migrations.run(this.database);
|
||||
}
|
||||
|
||||
async getMutableState<T extends object>(key: string, initialState: T) {
|
||||
const cached = this.mutableState.get(key);
|
||||
if (cached) return cached as MutableState<T>;
|
||||
|
||||
const state = new MutableState<T>(this.database, key, initialState);
|
||||
await state.read();
|
||||
this.mutableState.set(key, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
async saveAll() {
|
||||
for (const [key, state] of this.mutableState) {
|
||||
await state.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { EventEmitter } from "events";
|
||||
import { Database } from "better-sqlite3";
|
||||
import _throttle from "lodash.throttle";
|
||||
import { Debugger } from "debug";
|
||||
|
||||
import { logger } from "../../logger.js";
|
||||
|
||||
type EventMap<T> = {
|
||||
/** fires when file is loaded */
|
||||
loaded: [T];
|
||||
/** fires when a field is set */
|
||||
changed: [T, string, any];
|
||||
/** fires when state is loaded or changed */
|
||||
updated: [T];
|
||||
saved: [T];
|
||||
};
|
||||
|
||||
export class MutableState<T extends object> extends EventEmitter<EventMap<T>> {
|
||||
state?: T;
|
||||
log: Debugger;
|
||||
|
||||
private _proxy?: T;
|
||||
|
||||
/** A Proxy object that will automatically save when mutated */
|
||||
get proxy() {
|
||||
if (!this._proxy) throw new Error("Cant access state before initialized");
|
||||
return this._proxy;
|
||||
}
|
||||
|
||||
key: string;
|
||||
database: Database;
|
||||
|
||||
constructor(database: Database, key: string, initialState: T) {
|
||||
super();
|
||||
this.state = initialState;
|
||||
this.key = key;
|
||||
this.database = database;
|
||||
this.log = logger.extend(`State:` + key);
|
||||
this.createProxy();
|
||||
}
|
||||
|
||||
private createProxy() {
|
||||
if (!this.state) return;
|
||||
|
||||
return (this._proxy = new Proxy(this.state, {
|
||||
get(target, prop, receiver) {
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
set: (target, p, newValue, receiver) => {
|
||||
Reflect.set(target, p, newValue, receiver);
|
||||
this.emit("changed", target as T, String(p), newValue);
|
||||
this.emit("updated", target as T);
|
||||
this.throttleSave();
|
||||
return newValue;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private throttleSave = _throttle(this.save.bind(this), 30_000);
|
||||
|
||||
async read() {
|
||||
const row = await this.database
|
||||
.prepare<[string], { id: string; state: string }>(`SELECT id, state FROM application_state WHERE id=?`)
|
||||
.get(this.key);
|
||||
|
||||
const state: T | undefined = row ? (JSON.parse(row.state) as T) : undefined;
|
||||
if (state && this.state) {
|
||||
Object.assign(this.state, state);
|
||||
this.log("Loaded");
|
||||
}
|
||||
|
||||
if (!this.state) throw new Error(`Missing initial state for ${this.key}`);
|
||||
|
||||
this.createProxy();
|
||||
|
||||
if (this.state) {
|
||||
this.emit("loaded", this.state);
|
||||
this.emit("updated", this.state);
|
||||
}
|
||||
}
|
||||
async save() {
|
||||
if (!this.state) return;
|
||||
|
||||
await this.database
|
||||
.prepare<[string, string]>(`INSERT OR REPLACE INTO application_state (id, state) VALUES (?, ?)`)
|
||||
.run(this.key, JSON.stringify(this.state));
|
||||
|
||||
this.log("Saved");
|
||||
this.emit("saved", this.state);
|
||||
}
|
||||
}
|
||||
6
src/services/app-state.ts
Normal file
6
src/services/app-state.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import ApplicationStateManager from "../modules/application-state/manager.js";
|
||||
import bakeryDatabase from "../db/index.js";
|
||||
|
||||
const stateManager = new ApplicationStateManager(bakeryDatabase);
|
||||
|
||||
export default stateManager;
|
||||
@@ -1,7 +0,0 @@
|
||||
import LocalDatabase from "../app/database.js";
|
||||
import { DATA_PATH } from "../env.js";
|
||||
|
||||
// setup database
|
||||
const database = new LocalDatabase({ directory: DATA_PATH });
|
||||
|
||||
export default database;
|
||||
@@ -1,7 +1,6 @@
|
||||
import bakeryDatabase from "../db/database.js";
|
||||
import { SQLiteEventStore } from "../sqlite/event-store.js";
|
||||
import database from "./database.js";
|
||||
|
||||
const eventCache = new SQLiteEventStore(database.db);
|
||||
await eventCache.setup();
|
||||
const eventCache = new SQLiteEventStore(bakeryDatabase);
|
||||
|
||||
export default eventCache;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import LogStore from "../modules/log-store/log-store.js";
|
||||
import database from "./database.js";
|
||||
import bakeryDatabase from "../db/index.js";
|
||||
|
||||
const logStore = new LogStore(database.db);
|
||||
await logStore.setup();
|
||||
const logStore = new LogStore(bakeryDatabase);
|
||||
|
||||
export default logStore;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import database from "../../database.js";
|
||||
import { count } from "drizzle-orm";
|
||||
|
||||
import mcpServer from "../server.js";
|
||||
import bakeryDatabase, { schema } from "../../../db/index.js";
|
||||
|
||||
mcpServer.tool("get_database_stats", "Get the total number of events in the database", {}, async () => {
|
||||
const { events } = database.db.prepare<[], { events: number }>(`SELECT COUNT(*) AS events FROM events`).get() || {};
|
||||
const events = await bakeryDatabase.$count(schema.events);
|
||||
const { users } =
|
||||
database.db.prepare<[], { users: number }>(`SELECT COUNT(*) AS users FROM events GROUP BY pubkey`).get() || {};
|
||||
bakeryDatabase.select({ users: count() }).from(schema.events).groupBy(schema.events.pubkey).get() || {};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: [`Total events: ${events ?? 0}`, `Total users: ${users ?? 0}`].join("\n") }],
|
||||
content: [{ type: "text", text: [`Total events: ${events}`, `Total users: ${users ?? 0}`].join("\n") }],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import ApplicationStateManager from "../modules/state/application-state-manager.js";
|
||||
import database from "./database.js";
|
||||
|
||||
const stateManager = new ApplicationStateManager(database.db);
|
||||
await stateManager.setup();
|
||||
|
||||
export default stateManager;
|
||||
@@ -1,184 +1,20 @@
|
||||
import { ISyncEventStore } from "applesauce-core";
|
||||
import { Database } from "better-sqlite3";
|
||||
import { Filter, NostrEvent, kinds } from "nostr-tools";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { mapParams } from "../helpers/sql.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { MigrationSet } from "../sqlite/migrations.js";
|
||||
|
||||
const isFilterKeyIndexableTag = (key: string) => {
|
||||
return key[0] === "#" && key.length === 2;
|
||||
};
|
||||
const isFilterKeyIndexableAndTag = (key: string) => {
|
||||
return key[0] === "&" && key.length === 2;
|
||||
};
|
||||
|
||||
export type EventRow = {
|
||||
id: string;
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
created_at: number;
|
||||
sig: string;
|
||||
d?: string;
|
||||
};
|
||||
|
||||
export function parseEventRow(row: EventRow): NostrEvent {
|
||||
return { ...row, tags: JSON.parse(row.tags) };
|
||||
}
|
||||
|
||||
// search behavior
|
||||
const SEARCHABLE_TAGS = ["title", "description", "about", "summary", "alt"];
|
||||
const SEARCHABLE_KIND_BLACKLIST = [kinds.EncryptedDirectMessage];
|
||||
const SEARCHABLE_CONTENT_FORMATTERS: Record<number, (content: string) => string> = {
|
||||
[kinds.Metadata]: (content) => {
|
||||
const SEARCHABLE_PROFILE_FIELDS = [
|
||||
"name",
|
||||
"display_name",
|
||||
"about",
|
||||
"nip05",
|
||||
"lud16",
|
||||
"website",
|
||||
// Deprecated fields
|
||||
"displayName",
|
||||
"username",
|
||||
];
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
const json = JSON.parse(content);
|
||||
|
||||
for (const field of SEARCHABLE_PROFILE_FIELDS) {
|
||||
if (json[field]) lines.push(json[field]);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
} catch (error) {
|
||||
return content;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function convertEventToSearchRow(event: NostrEvent) {
|
||||
const tags = event.tags
|
||||
.filter((t) => SEARCHABLE_TAGS.includes(t[0]))
|
||||
.map((t) => t[1])
|
||||
.join(" ");
|
||||
|
||||
const content = SEARCHABLE_CONTENT_FORMATTERS[event.kind]
|
||||
? SEARCHABLE_CONTENT_FORMATTERS[event.kind](event.content)
|
||||
: event.content;
|
||||
|
||||
return { id: event.id, content, tags };
|
||||
}
|
||||
|
||||
const migrations = new MigrationSet("event-store");
|
||||
|
||||
// Version 1
|
||||
migrations.addScript(1, async (db, log) => {
|
||||
// Create events table
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT(64) PRIMARY KEY,
|
||||
created_at INTEGER,
|
||||
pubkey TEXT(64),
|
||||
sig TEXT(128),
|
||||
kind INTEGER,
|
||||
content TEXT,
|
||||
tags TEXT
|
||||
)
|
||||
`,
|
||||
).run();
|
||||
|
||||
log("Setup events");
|
||||
|
||||
// Create tags table
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
i INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
e TEXT(64) REFERENCES events(id),
|
||||
t TEXT(1),
|
||||
v TEXT
|
||||
)
|
||||
`,
|
||||
).run();
|
||||
|
||||
log("Setup tags table");
|
||||
|
||||
// Create indices
|
||||
const indices = [
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS events_created_at ON events(created_at)"),
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS events_pubkey ON events(pubkey)"),
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS events_kind ON events(kind)"),
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS tags_e ON tags(e)"),
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS tags_t ON tags(t)"),
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS tags_v ON tags(v)"),
|
||||
];
|
||||
|
||||
indices.forEach((statement) => statement.run());
|
||||
|
||||
log(`Setup ${indices.length} indices`);
|
||||
});
|
||||
|
||||
// Version 2, search table
|
||||
migrations.addScript(2, async (db, log) => {
|
||||
db.prepare(
|
||||
`CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(id UNINDEXED, content, tags, tokenize='trigram')`,
|
||||
).run();
|
||||
log("Created event search table");
|
||||
|
||||
const rows = db
|
||||
.prepare<number[], EventRow>(`SELECT * FROM events WHERE kind NOT IN ${mapParams(SEARCHABLE_KIND_BLACKLIST)}`)
|
||||
.all(...SEARCHABLE_KIND_BLACKLIST);
|
||||
|
||||
// insert search content into table
|
||||
let changes = 0;
|
||||
for (const row of rows) {
|
||||
const search = convertEventToSearchRow(parseEventRow(row));
|
||||
|
||||
const result = db
|
||||
.prepare<[string, string, string]>(`INSERT OR REPLACE INTO events_fts (id, content, tags) VALUES (?, ?, ?)`)
|
||||
.run(search.id, search.content, search.tags);
|
||||
|
||||
changes += result.changes;
|
||||
}
|
||||
log(`Inserted ${changes} events into search table`);
|
||||
});
|
||||
|
||||
// Version 3, indexed d tags
|
||||
migrations.addScript(3, async (db, log) => {
|
||||
db.prepare(`ALTER TABLE events ADD COLUMN d TEXT`).run();
|
||||
log("Created d column");
|
||||
|
||||
db.prepare("CREATE INDEX IF NOT EXISTS events_d ON events(d)").run();
|
||||
log(`Created d index`);
|
||||
|
||||
log(`Adding d tags to events table`);
|
||||
let updated = 0;
|
||||
db.transaction(() => {
|
||||
const events = db
|
||||
.prepare<[], { id: string; d: string }>(
|
||||
`
|
||||
SELECT events.id as id, tags.v as d
|
||||
FROM events
|
||||
INNER JOIN tags ON tags.e = events.id AND tags.t = 'd'
|
||||
WHERE events.kind >= 30000 AND events.kind < 40000
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
const update = db.prepare<[string, string]>("UPDATE events SET d = ? WHERE id = ?");
|
||||
|
||||
for (const row of events) {
|
||||
const { changes } = update.run(row.d, row.id);
|
||||
if (changes > 0) updated++;
|
||||
}
|
||||
})();
|
||||
|
||||
log(`Updated ${updated} events`);
|
||||
});
|
||||
import { insertEventIntoSearch, removeEventsFromSearch } from "../db/search/events.js";
|
||||
import { BakeryDatabase, schema } from "../db/index.js";
|
||||
import { parseEventRow } from "../db/helpers.js";
|
||||
import {
|
||||
addressableHistoryQuery,
|
||||
addressableQuery,
|
||||
buildSQLQueryForFilters,
|
||||
eventQuery,
|
||||
replaceableHistoryQuery,
|
||||
replaceableQuery,
|
||||
} from "../db/queries.js";
|
||||
|
||||
type EventMap = {
|
||||
"event:inserted": [NostrEvent];
|
||||
@@ -186,114 +22,89 @@ type EventMap = {
|
||||
};
|
||||
|
||||
export class SQLiteEventStore extends EventEmitter<EventMap> implements ISyncEventStore {
|
||||
db: Database;
|
||||
log = logger.extend("sqlite-event-store");
|
||||
|
||||
preserveEphemeral = false;
|
||||
preserveReplaceable = false;
|
||||
keepHistory = false;
|
||||
|
||||
constructor(db: Database) {
|
||||
constructor(public database: BakeryDatabase) {
|
||||
super();
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
setup() {
|
||||
return migrations.run(this.db);
|
||||
}
|
||||
|
||||
addEvent(event: NostrEvent) {
|
||||
addEvent(event: NostrEvent): boolean {
|
||||
// Don't store ephemeral events in db,
|
||||
// just return the event directly
|
||||
if (!this.preserveEphemeral && kinds.isEphemeralKind(event.kind)) return false;
|
||||
|
||||
const inserted = this.db.transaction(() => {
|
||||
// TODO: Check if event is replaceable and if its newer
|
||||
// before inserting it into the database
|
||||
// Check if the event is already in the database
|
||||
if (eventQuery.execute({ id: event.id }).sync() !== undefined) return false;
|
||||
|
||||
// get event d value so it can be indexed
|
||||
const d = kinds.isAddressableKind(event.kind) ? event.tags.find((t) => t[0] === "d" && t[1])?.[1] : undefined;
|
||||
// Get the replaceable identifier for the event
|
||||
const identifier =
|
||||
kinds.isReplaceableKind(event.kind) || !kinds.isAddressableKind(event.kind)
|
||||
? undefined
|
||||
: event.tags.find((t) => t[0] === "d" && t[1])?.[1];
|
||||
|
||||
const insert = this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT OR IGNORE INTO events (id, created_at, pubkey, sig, kind, content, tags, d)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
event.id,
|
||||
event.created_at,
|
||||
event.pubkey,
|
||||
event.sig,
|
||||
event.kind,
|
||||
event.content,
|
||||
JSON.stringify(event.tags),
|
||||
d,
|
||||
);
|
||||
// Check if the event is already in the database
|
||||
if (this.keepHistory === false && kinds.isReplaceableKind(event.kind)) {
|
||||
// Only check for newer events if we're not keeping history
|
||||
if (this.keepHistory === false) {
|
||||
const existing = replaceableQuery
|
||||
.execute({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier,
|
||||
})
|
||||
.sync();
|
||||
|
||||
// If event inserted, index tags, insert search
|
||||
if (insert.changes) {
|
||||
// Found a newer event, exit
|
||||
if (existing !== undefined) return false;
|
||||
}
|
||||
} else if (this.keepHistory === false && kinds.isAddressableKind(event.kind)) {
|
||||
const existing = addressableQuery
|
||||
.execute({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier,
|
||||
})
|
||||
.sync();
|
||||
|
||||
// Found a newer event, exit
|
||||
if (existing !== undefined) return false;
|
||||
}
|
||||
|
||||
// Attempt to insert the event into the database
|
||||
const inserted = this.database.transaction(() => {
|
||||
const insert = this.database
|
||||
.insert(schema.events)
|
||||
.values({
|
||||
id: event.id,
|
||||
created_at: event.created_at,
|
||||
pubkey: event.pubkey,
|
||||
sig: event.sig,
|
||||
kind: event.kind,
|
||||
content: event.content,
|
||||
tags: JSON.stringify(event.tags),
|
||||
identifier: identifier ?? null,
|
||||
})
|
||||
.run();
|
||||
|
||||
// Insert indexed tags
|
||||
this.insertEventTags(event);
|
||||
|
||||
// Remove older replaceable events and all their associated tags
|
||||
if (this.preserveReplaceable === false) {
|
||||
let older: { id: string; created_at: number }[] = [];
|
||||
|
||||
if (kinds.isReplaceableKind(event.kind)) {
|
||||
// Normal replaceable event
|
||||
older = this.db
|
||||
.prepare<[number, string], { id: string; created_at: number }>(
|
||||
`
|
||||
SELECT id, created_at FROM events WHERE kind = ? AND pubkey = ?
|
||||
`,
|
||||
)
|
||||
.all(event.kind, event.pubkey);
|
||||
} else if (kinds.isParameterizedReplaceableKind(event.kind)) {
|
||||
// Parameterized Replaceable
|
||||
const d = event.tags.find((t) => t[0] === "d")?.[1];
|
||||
|
||||
if (d) {
|
||||
older = this.db
|
||||
.prepare<[number, string, "d", string], { id: string; created_at: number }>(
|
||||
`
|
||||
SELECT events.id, events.created_at FROM events
|
||||
INNER JOIN tags ON events.id = tags.e
|
||||
WHERE kind = ? AND pubkey = ? AND tags.t = ? AND tags.v = ?
|
||||
`,
|
||||
)
|
||||
.all(event.kind, event.pubkey, "d", d);
|
||||
}
|
||||
}
|
||||
|
||||
// If found other events that may need to be replaced,
|
||||
// sort the events according to timestamp descending,
|
||||
// falling back to id lexical order ascending as per
|
||||
// NIP-01. Remove all non-most-recent events and tags.
|
||||
if (older.length > 1) {
|
||||
const removeIds = older
|
||||
.sort((a, b) => {
|
||||
return a.created_at === b.created_at ? a.id.localeCompare(b.id) : b.created_at - a.created_at;
|
||||
})
|
||||
.slice(1)
|
||||
.map((item) => item.id);
|
||||
|
||||
this.removeEvents(removeIds);
|
||||
|
||||
// If the event that was just inserted was one of
|
||||
// the events that was removed, return null so to
|
||||
// indicate that the event was in effect *not*
|
||||
// upserted and thus, if using the DB for a nostr
|
||||
// relay, does not need to be pushed to clients
|
||||
if (removeIds.indexOf(event.id) !== -1) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return insert.changes > 0;
|
||||
})();
|
||||
});
|
||||
|
||||
if (inserted) {
|
||||
// Remove older replaceable events if we're not keeping history
|
||||
if (this.keepHistory === false) {
|
||||
this.removeReplaceableHistory(event.kind, event.pubkey, identifier);
|
||||
}
|
||||
|
||||
// Index the event
|
||||
this.insertEventIntoSearch(event);
|
||||
|
||||
// Emit the event
|
||||
this.emit("event:inserted", event);
|
||||
}
|
||||
|
||||
@@ -303,231 +114,118 @@ export class SQLiteEventStore extends EventEmitter<EventMap> implements ISyncEve
|
||||
private insertEventTags(event: NostrEvent) {
|
||||
for (let tag of event.tags) {
|
||||
if (tag[0].length === 1) {
|
||||
this.db.prepare(`INSERT INTO tags (e, t, v) VALUES (?, ?, ?)`).run(event.id, tag[0], tag[1]);
|
||||
this.database.insert(schema.tags).values({ event: event.id, tag: tag[0], value: tag[1] }).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private insertEventIntoSearch(event: NostrEvent) {
|
||||
const search = convertEventToSearchRow(event);
|
||||
|
||||
return this.db
|
||||
.prepare<[string, string, string]>(`INSERT OR REPLACE INTO events_fts (id, content, tags) VALUES (?, ?, ?)`)
|
||||
.run(search.id, search.content, search.tags);
|
||||
return insertEventIntoSearch(this.database.$client, event);
|
||||
}
|
||||
|
||||
removeEvents(ids: string[]) {
|
||||
const results = this.db.transaction(() => {
|
||||
this.db.prepare(`DELETE FROM tags WHERE e IN ${mapParams(ids)}`).run(...ids);
|
||||
this.db.prepare(`DELETE FROM events_fts WHERE id IN ${mapParams(ids)}`).run(...ids);
|
||||
protected removeReplaceableHistory(kind: number, pubkey: string, identifier?: string): number {
|
||||
const existing = this.getReplaceableHistory(kind, pubkey, identifier);
|
||||
|
||||
return this.db.prepare(`DELETE FROM events WHERE events.id IN ${mapParams(ids)}`).run(...ids);
|
||||
})();
|
||||
// If there is more than one event, remove the older ones
|
||||
if (existing.length > 1) {
|
||||
const removeIds = existing
|
||||
// ignore the first event
|
||||
.slice(1)
|
||||
// get the ids of all the older events
|
||||
.map((item) => item.id);
|
||||
|
||||
this.removeEvents(removeIds);
|
||||
|
||||
return removeIds.length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
removeEvents(ids: string[]): number {
|
||||
// Remove the events from the fts search table
|
||||
removeEventsFromSearch(this.database.$client, ids);
|
||||
|
||||
const results = this.database.transaction(() => {
|
||||
// Delete from tags first
|
||||
this.database.delete(schema.tags).where(inArray(schema.tags.event, ids)).run();
|
||||
// Then delete from events
|
||||
return this.database.delete(schema.events).where(inArray(schema.events.id, ids)).run();
|
||||
});
|
||||
|
||||
if (results.changes > 0) {
|
||||
for (const id of ids) {
|
||||
this.emit("event:removed", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected buildConditionsForFilters(filter: Filter) {
|
||||
const joins: string[] = [];
|
||||
const conditions: string[] = [];
|
||||
const parameters: (string | number)[] = [];
|
||||
const groupBy: string[] = [];
|
||||
const having: string[] = [];
|
||||
|
||||
// get AND tag filters
|
||||
const andTagQueries = Object.keys(filter).filter(isFilterKeyIndexableAndTag);
|
||||
// get OR tag filters and remove any ones that appear in the AND
|
||||
const orTagQueries = Object.keys(filter)
|
||||
.filter(isFilterKeyIndexableTag)
|
||||
.filter((t) => !andTagQueries.includes(t));
|
||||
|
||||
if (orTagQueries.length > 0) {
|
||||
joins.push("INNER JOIN tags as or_tags ON events.id = or_tags.e");
|
||||
}
|
||||
if (andTagQueries.length > 0) {
|
||||
joins.push("INNER JOIN tags as and_tags ON events.id = and_tags.e");
|
||||
}
|
||||
if (filter.search) {
|
||||
joins.push("INNER JOIN events_fts ON events_fts.id = events.id");
|
||||
|
||||
conditions.push(`events_fts MATCH ?`);
|
||||
parameters.push('"' + filter.search.replace(/"/g, '""') + '"');
|
||||
}
|
||||
|
||||
if (typeof filter.since === "number") {
|
||||
conditions.push(`events.created_at >= ?`);
|
||||
parameters.push(filter.since);
|
||||
}
|
||||
|
||||
if (typeof filter.until === "number") {
|
||||
conditions.push(`events.created_at < ?`);
|
||||
parameters.push(filter.until);
|
||||
}
|
||||
|
||||
if (filter.ids) {
|
||||
conditions.push(`events.id IN ${mapParams(filter.ids)}`);
|
||||
parameters.push(...filter.ids);
|
||||
}
|
||||
|
||||
if (filter.kinds) {
|
||||
conditions.push(`events.kind IN ${mapParams(filter.kinds)}`);
|
||||
parameters.push(...filter.kinds);
|
||||
}
|
||||
|
||||
if (filter.authors) {
|
||||
conditions.push(`events.pubkey IN ${mapParams(filter.authors)}`);
|
||||
parameters.push(...filter.authors);
|
||||
}
|
||||
|
||||
// add AND tag filters
|
||||
for (const t of andTagQueries) {
|
||||
conditions.push(`and_tags.t = ?`);
|
||||
parameters.push(t.slice(1));
|
||||
|
||||
// @ts-expect-error
|
||||
const v = filter[t] as string[];
|
||||
conditions.push(`and_tags.v IN ${mapParams(v)}`);
|
||||
parameters.push(...v);
|
||||
}
|
||||
|
||||
// add OR tag filters
|
||||
for (let t of orTagQueries) {
|
||||
conditions.push(`or_tags.t = ?`);
|
||||
parameters.push(t.slice(1));
|
||||
|
||||
// @ts-expect-error
|
||||
const v = filter[t] as string[];
|
||||
conditions.push(`or_tags.v IN ${mapParams(v)}`);
|
||||
parameters.push(...v);
|
||||
}
|
||||
|
||||
// if there is an AND tag filter set GROUP BY so that HAVING can be used
|
||||
if (andTagQueries.length > 0) {
|
||||
groupBy.push("events.id");
|
||||
having.push("COUNT(and_tags.i) = ?");
|
||||
|
||||
// @ts-expect-error
|
||||
parameters.push(andTagQueries.reduce((t, k) => t + (filter[k] as string[]).length, 0));
|
||||
}
|
||||
|
||||
return { conditions, parameters, joins, groupBy, having };
|
||||
}
|
||||
|
||||
protected buildSQLQueryForFilters(filters: Filter[], select = "events.*") {
|
||||
let sql = `SELECT ${select} FROM events `;
|
||||
|
||||
const orConditions: string[] = [];
|
||||
const parameters: any[] = [];
|
||||
const groupBy = new Set<string>();
|
||||
const having = new Set<string>();
|
||||
|
||||
let joins = new Set<string>();
|
||||
for (const filter of filters) {
|
||||
const parts = this.buildConditionsForFilters(filter);
|
||||
|
||||
if (parts.conditions.length > 0) {
|
||||
orConditions.push(`(${parts.conditions.join(" AND ")})`);
|
||||
parameters.push(...parts.parameters);
|
||||
|
||||
for (const join of parts.joins) joins.add(join);
|
||||
for (const group of parts.groupBy) groupBy.add(group);
|
||||
for (const have of parts.having) having.add(have);
|
||||
}
|
||||
}
|
||||
|
||||
sql += Array.from(joins).join(" ");
|
||||
|
||||
if (orConditions.length > 0) {
|
||||
sql += ` WHERE ${orConditions.join(" OR ")}`;
|
||||
}
|
||||
|
||||
if (groupBy.size > 0) {
|
||||
sql += " GROUP BY " + Array.from(groupBy).join(",");
|
||||
}
|
||||
if (having.size > 0) {
|
||||
sql += " HAVING " + Array.from(having).join(" AND ");
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
const order = filters.find((f) => f.order)?.order;
|
||||
if (filters.some((f) => f.search) && (order === "rank" || order === undefined)) {
|
||||
sql = sql + " ORDER BY rank";
|
||||
} else {
|
||||
sql = sql + " ORDER BY created_at DESC";
|
||||
}
|
||||
|
||||
let minLimit = Infinity;
|
||||
for (const filter of filters) {
|
||||
if (filter.limit) minLimit = Math.min(minLimit, filter.limit);
|
||||
}
|
||||
if (minLimit !== Infinity) {
|
||||
sql += " LIMIT ?";
|
||||
parameters.push(minLimit);
|
||||
}
|
||||
|
||||
return { sql, parameters };
|
||||
return results.changes;
|
||||
}
|
||||
|
||||
hasEvent(id: string): boolean {
|
||||
return this.db.prepare<[string], { id: string }>(`SELECT id FROM events WHERE id = ?`).get(id) !== undefined;
|
||||
return this.database.select().from(schema.events).where(eq(schema.events.id, id)).get() !== undefined;
|
||||
}
|
||||
|
||||
getEvent(id: string): NostrEvent | undefined {
|
||||
const row = this.db.prepare<[string], EventRow>(`SELECT * FROM events WHERE id = ?`).get(id);
|
||||
const row = this.database.select().from(schema.events).where(eq(schema.events.id, id)).get();
|
||||
if (!row) return undefined;
|
||||
return parseEventRow(row);
|
||||
}
|
||||
|
||||
protected getReplaceableQuery(kind: number, pubkey: string, identifier?: string) {
|
||||
if (kinds.isAddressableKind(kind)) {
|
||||
return addressableQuery.execute({ kind, pubkey, identifier });
|
||||
} else if (kinds.isReplaceableKind(kind)) {
|
||||
return replaceableQuery.execute({ kind, pubkey });
|
||||
} else throw new Error("Regular events are not replaceable");
|
||||
}
|
||||
hasReplaceable(kind: number, pubkey: string, identifier?: string): boolean {
|
||||
return this.getReplaceable(kind, pubkey, identifier) !== undefined;
|
||||
return this.getReplaceableQuery(kind, pubkey, identifier).sync() !== undefined;
|
||||
}
|
||||
|
||||
getReplaceable(kind: number, pubkey: string, identifier?: string): NostrEvent | undefined {
|
||||
const filter: Filter = { kinds: [kind], authors: [pubkey], limit: 1 };
|
||||
if (identifier) filter["#d"] = [identifier];
|
||||
return this.getEventsForFilters([filter])[0];
|
||||
}
|
||||
const row = this.getReplaceableQuery(kind, pubkey, identifier).sync();
|
||||
if (!row) return undefined;
|
||||
|
||||
return parseEventRow(row);
|
||||
}
|
||||
getReplaceableHistory(kind: number, pubkey: string, identifier?: string): NostrEvent[] {
|
||||
const filter: Filter = { kinds: [kind], authors: [pubkey] };
|
||||
if (identifier) filter["#d"] = [identifier];
|
||||
return this.getEventsForFilters([filter]);
|
||||
if (kinds.isRegularKind(kind)) throw new Error("Regular events are not replaceable");
|
||||
|
||||
const query = kinds.isAddressableKind(kind)
|
||||
? addressableHistoryQuery.execute({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier,
|
||||
})
|
||||
: replaceableHistoryQuery.execute({
|
||||
kind,
|
||||
pubkey,
|
||||
});
|
||||
|
||||
return query.sync().map(parseEventRow);
|
||||
}
|
||||
getTimeline(filters: Filter | Filter[]): NostrEvent[] {
|
||||
return this.getEventsForFilters(Array.isArray(filters) ? filters : [filters]);
|
||||
}
|
||||
|
||||
getAll(filters: Filter | Filter[]): Set<NostrEvent> {
|
||||
return new Set(this.getEventsForFilters(Array.isArray(filters) ? filters : [filters]));
|
||||
}
|
||||
|
||||
// TODO: Update this to use drizzle
|
||||
getEventsForFilters(filters: Filter[]) {
|
||||
const { sql, parameters } = this.buildSQLQueryForFilters(filters);
|
||||
const { stmt, parameters } = buildSQLQueryForFilters(filters);
|
||||
|
||||
return this.db.prepare<any[], EventRow>(sql).all(parameters).map(parseEventRow);
|
||||
}
|
||||
|
||||
*iterateEventsForFilters(filters: Filter[]): IterableIterator<NostrEvent> {
|
||||
const { sql, parameters } = this.buildSQLQueryForFilters(filters);
|
||||
const iterator = this.db.prepare<any[], EventRow>(sql).iterate(parameters);
|
||||
|
||||
while (true) {
|
||||
const { value: row, done } = iterator.next();
|
||||
if (done) break;
|
||||
|
||||
yield parseEventRow(row);
|
||||
}
|
||||
return this.database.$client
|
||||
.prepare<any[], typeof schema.events.$inferSelect>(stmt)
|
||||
.all(parameters)
|
||||
.map(parseEventRow);
|
||||
}
|
||||
|
||||
// TODO: Update this to use drizzle
|
||||
countEventsForFilters(filters: Filter[]) {
|
||||
const { sql, parameters } = this.buildSQLQueryForFilters(filters);
|
||||
const { stmt, parameters } = buildSQLQueryForFilters(filters);
|
||||
|
||||
const results = this.db
|
||||
.prepare<any[], { count: number }>(`SELECT COUNT(*) as count FROM ( ${sql} )`)
|
||||
const results = this.database.$client
|
||||
.prepare<any[], { count: number }>(`SELECT COUNT(*) as count FROM ( ${stmt} )`)
|
||||
.get(parameters) as { count: number } | undefined;
|
||||
return results?.count ?? 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user