From 755204c17a57d86bde805ab754eaef1d989a1030 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Sat, 29 Mar 2025 12:45:28 +0000 Subject: [PATCH] use drizzle orm for database --- .vscode/launch.json | 1 + drizzle.config.ts | 11 + drizzle/0000_fluffy_risque.sql | 44 + drizzle/meta/0000_snapshot.json | 287 ++++++ drizzle/meta/_journal.json | 13 + package.json | 6 +- pnpm-lock.yaml | 900 +++++++++++++++++- src/app/database.ts | 86 -- src/app/index.ts | 20 +- src/classes/json-file.ts | 6 + src/db/database.ts | 22 + src/db/helpers.ts | 20 + src/db/index.ts | 3 + src/db/queries.ts | 270 ++++++ src/db/schema.ts | 58 ++ src/db/search/decrypted.ts | 96 ++ src/db/search/events.ts | 101 ++ src/env.ts | 2 + src/index.ts | 16 +- src/modules/application-state/manager.ts | 94 ++ src/modules/control/database-actions.ts | 65 -- src/modules/control/decryption-cache.ts | 13 +- .../decryption-cache/decryption-cache.ts | 143 +-- src/modules/log-store/log-store.ts | 183 +--- .../notifications/notifications-manager.ts | 8 +- src/modules/queries/queries/config.ts | 10 +- src/modules/queries/queries/logs.ts | 9 +- src/modules/queries/queries/services.ts | 14 +- src/modules/scrapper/index.ts | 2 +- src/modules/scrapper/pubkey-scrapper.ts | 2 +- .../state/application-state-manager.ts | 49 - src/modules/state/mutable-state.ts | 91 -- src/services/app-state.ts | 6 + src/services/{bakery.ts => bakery-signer.ts} | 0 src/services/database.ts | 7 - src/services/event-cache.ts | 5 +- src/services/log-store.ts | 5 +- src/services/mcp/tools/database.ts | 10 +- src/services/state.ts | 7 - src/sqlite/event-store.ts | 590 +++--------- 40 files changed, 2199 insertions(+), 1076 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_fluffy_risque.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json delete mode 100644 src/app/database.ts create mode 100644 src/db/database.ts create mode 100644 src/db/helpers.ts create mode 100644 src/db/index.ts create mode 100644 src/db/queries.ts create mode 100644 src/db/schema.ts create mode 100644 src/db/search/decrypted.ts create mode 100644 src/db/search/events.ts create mode 100644 src/modules/application-state/manager.ts delete mode 100644 src/modules/control/database-actions.ts delete mode 100644 src/modules/state/application-state-manager.ts delete mode 100644 src/modules/state/mutable-state.ts create mode 100644 src/services/app-state.ts rename src/services/{bakery.ts => bakery-signer.ts} (100%) delete mode 100644 src/services/database.ts delete mode 100644 src/services/state.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 10cf0d8..1433949 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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", diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..2eb4745 --- /dev/null +++ b/drizzle.config.ts @@ -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!, + }, +}); diff --git a/drizzle/0000_fluffy_risque.sql b/drizzle/0000_fluffy_risque.sql new file mode 100644 index 0000000..2013265 --- /dev/null +++ b/drizzle/0000_fluffy_risque.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..a47380a --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..5fde496 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1743251841984, + "tag": "0000_fluffy_risque", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 96e9862..28f219a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25cf3c2..b29598d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@diva.exchange/i2p-sam': specifier: ^5.4.2 version: 5.4.2 + '@libsql/client': + specifier: ^0.15.1 + version: 0.15.1 '@modelcontextprotocol/sdk': specifier: ^1.8.0 version: 1.8.0 @@ -56,6 +59,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.4.7 + drizzle-orm: + specifier: ^0.41.0 + version: 0.41.0(@libsql/client@0.15.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.9.1)(gel@2.0.1) express: specifier: ^4.21.2 version: 4.21.2 @@ -168,18 +174,24 @@ importers: '@types/ws': specifier: ^8.18.0 version: 8.18.0 + drizzle-kit: + specifier: ^0.30.6 + version: 0.30.6 nodemon: specifier: ^3.1.9 version: 3.1.9 prettier: specifier: ^3.5.3 version: 3.5.3 + tsx: + specifier: ^4.19.3 + version: 4.19.3 typescript: specifier: ^5.8.2 version: 5.8.2 vitest: specifier: ^3.0.9 - version: 3.0.9(@types/debug@4.1.12)(@types/node@22.13.14) + version: 3.0.9(@types/debug@4.1.12)(@types/node@22.13.14)(tsx@4.19.3) packages: @@ -262,6 +274,9 @@ packages: resolution: {integrity: sha512-uaYZlSHdqxOQhtmPzO8g/Hzdp2Vd5rQ5wZMDPYeLUr2FOMYc0pwRpThyc6GfPQ8d0yTAbqemzu/uEGARxJ/gtA==} engines: {node: '>=16.0.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.3.1': resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==} @@ -271,102 +286,308 @@ packages: '@emnapi/wasi-threads@1.0.1': resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.1': resolution: {integrity: sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.1': resolution: {integrity: sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.1': resolution: {integrity: sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.1': resolution: {integrity: sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.1': resolution: {integrity: sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.1': resolution: {integrity: sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.1': resolution: {integrity: sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.1': resolution: {integrity: sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.1': resolution: {integrity: sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.1': resolution: {integrity: sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.1': resolution: {integrity: sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.1': resolution: {integrity: sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.1': resolution: {integrity: sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.1': resolution: {integrity: sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.1': resolution: {integrity: sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.1': resolution: {integrity: sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.1': resolution: {integrity: sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==} engines: {node: '>=18'} @@ -379,6 +600,18 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.1': resolution: {integrity: sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==} engines: {node: '>=18'} @@ -391,30 +624,90 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.1': resolution: {integrity: sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.1': resolution: {integrity: sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.1': resolution: {integrity: sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.1': resolution: {integrity: sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.1': resolution: {integrity: sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==} engines: {node: '>=18'} @@ -452,6 +745,57 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@libsql/client@0.15.1': + resolution: {integrity: sha512-BzAj/nEoiH8FhOrFCrN9NFll97iMbTUQA/RfZbc+Na/Iyu9w/vt1+0zLf7OaQCTeQ+7g5ScanLuRMidTcz9XgQ==} + + '@libsql/core@0.15.1': + resolution: {integrity: sha512-7RDXHxD+H1UTBG67mCVEjTmbJfcEo0bFFyb4wFaGWYrrKAkDcYq7BhG67lTqd4EHSzUV7Ur/lk6f/EBMplm+Kg==} + + '@libsql/darwin-arm64@0.5.3': + resolution: {integrity: sha512-yAitxSuiaLT465uAvqXi1TzirXb+IOxa6sfC+uIuyCzAusLEhOFlEhutqenVx93lhPkJZJIiZkK1pIETIatnfg==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.5.3': + resolution: {integrity: sha512-ZBVinaZcCxVoTuCTdW7vY97XIc13RjCEFG16Ix+zemtJbGFCTos7EUhkGWuWIWG/1HXYpFbXu4d5d7p6p4TlQA==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.7.0': + resolution: {integrity: sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw==} + + '@libsql/isomorphic-fetch@0.3.1': + resolution: {integrity: sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw==} + engines: {node: '>=18.0.0'} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/linux-arm64-gnu@0.5.3': + resolution: {integrity: sha512-20qUC4O16qhGl1GPIBABgroytzLoML5hhVYrM5jaYhRHg2kFN9PytKV+M75vlyLiJk7CJwkRUxD4Xwo4OODRVg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.5.3': + resolution: {integrity: sha512-sv19UXkNo+J6lGSfv4tKjViqciU5MHdtcmaiyJ5BcsEouwDLp5gDbyr1iJHoQw+nWpsiNp1OO4x8JoqC6sZHkA==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.5.3': + resolution: {integrity: sha512-fE/tkqGneXfMzQ5TY7ptoQXk8bRVGu6fVSmPiegCqmCcF457uO/7yqpQZyMav4viXAgmIkI15nyEo01ekaeBCg==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.5.3': + resolution: {integrity: sha512-g/JIAmLJcsmhubfDtw5K4ipVx89Kr7V1cuoTFc/jDtBQNpfLVDOtNii3TTxvLg/yH+HRipE5Cus8hJdtysJXcw==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.5.3': + resolution: {integrity: sha512-X7apIBRZNSSRh446NvUfq7AthUdH2OSLAAkzhOW48wCQxYheXU791WyOiroNl7s5HuIvJGAJUCR9hFLICYgWRg==} + cpu: [x64] + os: [win32] + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -477,6 +821,9 @@ packages: '@napi-rs/wasm-runtime@0.2.7': resolution: {integrity: sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==} + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + '@noble/ciphers@0.5.3': resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} @@ -572,6 +919,9 @@ packages: cpu: [x64] os: [win32] + '@petamoriken/float16@3.9.2': + resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + '@pondwader/socks5-server@1.0.10': resolution: {integrity: sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==} @@ -1659,6 +2009,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -1735,6 +2089,10 @@ packages: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -1760,6 +2118,99 @@ packages: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} + drizzle-kit@0.30.6: + resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} + hasBin: true + + drizzle-orm@0.41.0: + resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1791,6 +2242,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1806,6 +2261,21 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.1: resolution: {integrity: sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==} engines: {node: '>=18'} @@ -1907,6 +2377,10 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1943,6 +2417,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1974,6 +2452,11 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gel@2.0.1: + resolution: {integrity: sha512-gfem3IGvqKqXwEq7XseBogyaRwGsQGuE7Cw/yQsjLGdgiyqX92G1xENPCE0ltunPGcsJIa6XBOTx/PK169mOqw==} + engines: {node: '>= 18.0.0'} + hasBin: true + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1994,6 +2477,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@4.10.0: + resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} + get-uri@6.0.4: resolution: {integrity: sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==} engines: {node: '>= 14'} @@ -2189,6 +2675,13 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2214,6 +2707,10 @@ packages: kademlia-routing-table@1.0.6: resolution: {integrity: sha512-Ve6jwIlUCYvUzBnXnzVRHDZCFgXURW9gmF3r7n05kZs/2rNbLHXwGdcq0qIaSwdmJCvtosgR4JensnVU65hzNQ==} + libsql@0.5.3: + resolution: {integrity: sha512-S3WR8WNCJV1VXraBFUKjDA6+8LcNDJMLm+83qohm1O3YM1iVqV2+/XN3SXOxpxVjuL4g/rLrjO5kzygkPefCFQ==} + os: [darwin, linux, win32] + light-bolt11-decoder@3.2.0: resolution: {integrity: sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==} @@ -2471,6 +2968,10 @@ packages: resolution: {integrity: sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==} engines: {node: '>=10'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2480,6 +2981,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nodemon@3.1.9: resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} engines: {node: '>=10'} @@ -2650,6 +3155,9 @@ packages: process-streams@1.0.3: resolution: {integrity: sha512-xkIaM5vYnyekB88WyET78YEqXiaJRy0xcvIdE22n+myhvBT7LlLmX6iAtq7jDvVH8CUx2rqQsd32JdRyJMV3NA==} + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + protomux@3.10.1: resolution: {integrity: sha512-jgBqx8ZyaBWea/DFG4eOu1scOaeBwcnagiRC1XFVrjeGt7oAb0Pk5udPpBUpJ4DJBRjra50jD6YcZiQQTRqaaA==} @@ -2798,6 +3306,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3125,6 +3636,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.19.3: + resolution: {integrity: sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -3304,6 +3820,10 @@ packages: engines: {node: '>= 16'} hasBin: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -3319,6 +3839,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3575,6 +4100,8 @@ snapshots: nanoid: 5.1.5 rfc4648: 1.5.4 + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.3.1': dependencies: '@emnapi/wasi-threads': 1.0.1 @@ -3591,78 +4118,223 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.10.0 + + '@esbuild/aix-ppc64@0.19.12': + optional: true + '@esbuild/aix-ppc64@0.25.1': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + '@esbuild/android-arm64@0.25.1': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + '@esbuild/android-arm@0.25.1': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + '@esbuild/android-x64@0.25.1': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + '@esbuild/darwin-arm64@0.25.1': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + '@esbuild/darwin-x64@0.25.1': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + '@esbuild/freebsd-arm64@0.25.1': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + '@esbuild/freebsd-x64@0.25.1': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + '@esbuild/linux-arm64@0.25.1': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + '@esbuild/linux-arm@0.25.1': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + '@esbuild/linux-ia32@0.25.1': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + '@esbuild/linux-loong64@0.25.1': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + '@esbuild/linux-mips64el@0.25.1': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + '@esbuild/linux-ppc64@0.25.1': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + '@esbuild/linux-riscv64@0.25.1': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + '@esbuild/linux-s390x@0.25.1': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + '@esbuild/linux-x64@0.25.1': optional: true '@esbuild/netbsd-arm64@0.25.1': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + '@esbuild/netbsd-x64@0.25.1': optional: true '@esbuild/openbsd-arm64@0.25.1': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + '@esbuild/openbsd-x64@0.25.1': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + '@esbuild/sunos-x64@0.25.1': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + '@esbuild/win32-arm64@0.25.1': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + '@esbuild/win32-ia32@0.25.1': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + '@esbuild/win32-x64@0.25.1': optional: true @@ -3710,6 +4382,62 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@libsql/client@0.15.1': + dependencies: + '@libsql/core': 0.15.1 + '@libsql/hrana-client': 0.7.0 + js-base64: 3.7.7 + libsql: 0.5.3 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/core@0.15.1': + dependencies: + js-base64: 3.7.7 + + '@libsql/darwin-arm64@0.5.3': + optional: true + + '@libsql/darwin-x64@0.5.3': + optional: true + + '@libsql/hrana-client@0.7.0': + dependencies: + '@libsql/isomorphic-fetch': 0.3.1 + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.7.7 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/isomorphic-fetch@0.3.1': {} + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.0 + ws: 8.18.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@libsql/linux-arm64-gnu@0.5.3': + optional: true + + '@libsql/linux-arm64-musl@0.5.3': + optional: true + + '@libsql/linux-x64-gnu@0.5.3': + optional: true + + '@libsql/linux-x64-musl@0.5.3': + optional: true + + '@libsql/win32-x64-msvc@0.5.3': + optional: true + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.27.0 @@ -3812,6 +4540,8 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true + '@neon-rs/load@0.0.4': {} + '@noble/ciphers@0.5.3': {} '@noble/curves@1.1.0': @@ -3881,6 +4611,8 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@5.0.1': optional: true + '@petamoriken/float16@3.9.2': {} + '@pondwader/socks5-server@1.0.10': {} '@radix-ui/number@1.1.0': {} @@ -4440,13 +5172,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.0.9(vite@6.2.3(@types/node@22.13.14))': + '@vitest/mocker@3.0.9(vite@6.2.3(@types/node@22.13.14)(tsx@4.19.3))': dependencies: '@vitest/spy': 3.0.9 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 6.2.3(@types/node@22.13.14) + vite: 6.2.3(@types/node@22.13.14)(tsx@4.19.3) '@vitest/pretty-format@3.0.9': dependencies: @@ -5009,6 +5741,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} dayjs@1.11.13: {} @@ -5061,6 +5795,8 @@ snapshots: detect-indent@6.1.0: {} + detect-libc@2.0.2: {} + detect-libc@2.0.3: {} detect-node-es@1.1.0: {} @@ -5093,6 +5829,23 @@ snapshots: dotenv@16.4.7: {} + drizzle-kit@0.30.6: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.19.12 + esbuild-register: 3.6.0(esbuild@0.19.12) + gel: 2.0.1 + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.41.0(@libsql/client@0.15.1)(@types/better-sqlite3@7.6.12)(better-sqlite3@11.9.1)(gel@2.0.1): + optionalDependencies: + '@libsql/client': 0.15.1 + '@types/better-sqlite3': 7.6.12 + better-sqlite3: 11.9.1 + gel: 2.0.1 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5122,6 +5875,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + env-paths@3.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5132,6 +5887,64 @@ snapshots: dependencies: es-errors: 1.3.0 + esbuild-register@3.6.0(esbuild@0.19.12): + dependencies: + debug: 4.4.0(supports-color@5.5.0) + esbuild: 0.19.12 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + esbuild@0.25.1: optionalDependencies: '@esbuild/aix-ppc64': 0.25.1 @@ -5305,6 +6118,11 @@ snapshots: dependencies: reusify: 1.1.0 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-uri-to-path@1.0.0: {} fill-range@7.1.1: @@ -5349,6 +6167,10 @@ snapshots: dependencies: is-callable: 1.2.7 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -5374,6 +6196,17 @@ snapshots: function-bind@1.1.2: {} + gel@2.0.1: + dependencies: + '@petamoriken/float16': 3.9.2 + debug: 4.4.0(supports-color@5.5.0) + env-paths: 3.0.0 + semver: 7.7.1 + shell-quote: 1.8.2 + which: 4.0.0 + transitivePeerDependencies: + - supports-color + get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -5398,6 +6231,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-tsconfig@4.10.0: + dependencies: + resolve-pkg-maps: 1.0.0 + get-uri@6.0.4: dependencies: basic-ftp: 5.0.5 @@ -5626,6 +6463,10 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.1: {} + + js-base64@3.7.7: {} + js-tokens@4.0.0: {} js-yaml@3.14.1: @@ -5656,6 +6497,19 @@ snapshots: dependencies: bare-events: 2.5.4 + libsql@0.5.3: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.5.3 + '@libsql/darwin-x64': 0.5.3 + '@libsql/linux-arm64-gnu': 0.5.3 + '@libsql/linux-arm64-musl': 0.5.3 + '@libsql/linux-x64-gnu': 0.5.3 + '@libsql/linux-x64-musl': 0.5.3 + '@libsql/win32-x64-msvc': 0.5.3 + light-bolt11-decoder@3.2.0: dependencies: '@scure/base': 1.1.1 @@ -5970,10 +6824,18 @@ snapshots: dependencies: semver: 7.7.1 + node-domexception@1.0.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + nodemon@3.1.9: dependencies: chokidar: 3.6.0 @@ -6153,6 +7015,8 @@ snapshots: dependencies: duplex-maker: 1.0.0 + promise-limit@2.7.0: {} + protomux@3.10.1: dependencies: b4a: 1.6.7 @@ -6319,6 +7183,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rfc4648@1.5.4: {} @@ -6710,6 +7576,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.19.3: + dependencies: + esbuild: 0.25.1 + get-tsconfig: 4.10.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -6814,13 +7687,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite-node@3.0.9(@types/node@22.13.14): + vite-node@3.0.9(@types/node@22.13.14)(tsx@4.19.3): dependencies: cac: 6.7.14 debug: 4.4.0(supports-color@5.5.0) es-module-lexer: 1.6.0 pathe: 2.0.3 - vite: 6.2.3(@types/node@22.13.14) + vite: 6.2.3(@types/node@22.13.14)(tsx@4.19.3) transitivePeerDependencies: - '@types/node' - jiti @@ -6835,7 +7708,7 @@ snapshots: - tsx - yaml - vite@6.2.3(@types/node@22.13.14): + vite@6.2.3(@types/node@22.13.14)(tsx@4.19.3): dependencies: esbuild: 0.25.1 postcss: 8.5.3 @@ -6843,11 +7716,12 @@ snapshots: optionalDependencies: '@types/node': 22.13.14 fsevents: 2.3.3 + tsx: 4.19.3 - vitest@3.0.9(@types/debug@4.1.12)(@types/node@22.13.14): + vitest@3.0.9(@types/debug@4.1.12)(@types/node@22.13.14)(tsx@4.19.3): dependencies: '@vitest/expect': 3.0.9 - '@vitest/mocker': 3.0.9(vite@6.2.3(@types/node@22.13.14)) + '@vitest/mocker': 3.0.9(vite@6.2.3(@types/node@22.13.14)(tsx@4.19.3)) '@vitest/pretty-format': 3.0.9 '@vitest/runner': 3.0.9 '@vitest/snapshot': 3.0.9 @@ -6863,8 +7737,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.2.3(@types/node@22.13.14) - vite-node: 3.0.9(@types/node@22.13.14) + vite: 6.2.3(@types/node@22.13.14)(tsx@4.19.3) + vite-node: 3.0.9(@types/node@22.13.14)(tsx@4.19.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -6899,6 +7773,8 @@ snapshots: transitivePeerDependencies: - supports-color + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -6920,6 +7796,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/src/app/database.ts b/src/app/database.ts deleted file mode 100644 index dc2de82..0000000 --- a/src/app/database.ts +++ /dev/null @@ -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) { - 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(); - } -} diff --git a/src/app/index.ts b/src/app/index.ts index 746b85d..5208a48 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -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 { 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 { }); // 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 { 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 { 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 { 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(); diff --git a/src/classes/json-file.ts b/src/classes/json-file.ts index c4342a5..5df2f25 100644 --- a/src/classes/json-file.ts +++ b/src/classes/json-file.ts @@ -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 extends EventEmitter adapter: SyncAdapter; data: T; + data$: BehaviorSubject; constructor(path: string, defaultData: T) { super(); @@ -25,6 +27,7 @@ export class ReactiveJsonFile extends EventEmitter this.db = new LowSync(this.adapter, defaultData); this.data = this.createProxy(); + this.data$ = new BehaviorSubject(this.db.data); } private createProxy() { @@ -34,6 +37,7 @@ export class ReactiveJsonFile extends EventEmitter }, 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 extends EventEmitter 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) { diff --git a/src/db/database.ts b/src/db/database.ts new file mode 100644 index 0000000..e2d4370 --- /dev/null +++ b/src/db/database.ts @@ -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; diff --git a/src/db/helpers.ts b/src/db/helpers.ts new file mode 100644 index 0000000..7ed2cca --- /dev/null +++ b/src/db/helpers.ts @@ -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), + }; +} diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..6279c46 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,3 @@ +export { default, type BakeryDatabase } from "./database.js"; +export * as schema from "./schema.js"; +export * as helpers from "./helpers.js"; diff --git a/src/db/queries.ts b/src/db/queries.ts new file mode 100644 index 0000000..e68bc93 --- /dev/null +++ b/src/db/queries.ts @@ -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(); + const having = new Set(); + + let joins = new Set(); + 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; +// } diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..d038491 --- /dev/null +++ b/src/db/schema.ts @@ -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"), +}); diff --git a/src/db/search/decrypted.ts b/src/db/search/decrypted.ts new file mode 100644 index 0000000..b9d2721 --- /dev/null +++ b/src/db/search/decrypted.ts @@ -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(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; + }); +} diff --git a/src/db/search/events.ts b/src/db/search/events.ts new file mode 100644 index 0000000..bf51e43 --- /dev/null +++ b/src/db/search/events.ts @@ -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 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( + `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(`DELETE FROM events_fts WHERE id IN ${mapParams(events)}`).run(...events); + return result.changes > 0; +} diff --git a/src/env.ts b/src/env.ts index 42800da..71e1feb 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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 diff --git a/src/index.ts b/src/index.ts index b52a81b..7fc4fe5 100644 --- a/src/index.ts +++ b/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); diff --git a/src/modules/application-state/manager.ts b/src/modules/application-state/manager.ts new file mode 100644 index 0000000..49b0328 --- /dev/null +++ b/src/modules/application-state/manager.ts @@ -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( + 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(); + + // 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(); + constructor(public database: BakeryDatabase) {} + + getMutableState(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(); + } + } +} diff --git a/src/modules/control/database-actions.ts b/src/modules/control/database-actions.ts deleted file mode 100644 index 6df2fe0..0000000 --- a/src/modules/control/database-actions.ts +++ /dev/null @@ -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(); - - 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)); - } -} diff --git a/src/modules/control/decryption-cache.ts b/src/modules/control/decryption-cache.ts index cd6e3c4..874e239 100644 --- a/src/modules/control/decryption-cache.ts +++ b/src/modules/control/decryption-cache.ts @@ -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) => { - for (const { event, content } of contents) - this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "CONTENT", event, content]); - this.send(sock, ["CONTROL", "DECRYPTION-CACHE", "END"]); - }); + 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: diff --git a/src/modules/decryption-cache/decryption-cache.ts b/src/modules/decryption-cache/decryption-cache.ts index 31e2088..a46920a 100644 --- a/src/modules/decryption-cache/decryption-cache.ts +++ b/src/modules/decryption-cache/decryption-cache.ts @@ -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 { - 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(`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()); - } - - 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(sql) - .all(...params) - .map((row) => ({ event: parseEventRow(row), plaintext: row.plaintext })); + getEventContent(id: string): string | undefined { + return this.database.select().from(schema.decryptionCache).where(eq(schema.decryptionCache.event, id)).get() + ?.content; } - 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(); } } diff --git a/src/modules/log-store/log-store.ts b/src/modules/log-store/log-store.ts index fb6ee18..aa263ef 100644 --- a/src/modules/log-store/log-store.ts +++ b/src/modules/log-store/log-store.ts @@ -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(); + protected write$ = new Subject(); -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 { - 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 { 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); - } - - if (++i >= BATCH_SIZE) break; - } - })(); - - for (const entry of failed) { - // Don't know what to do here... - } - - if (this.queue.length > 0) setTimeout(this.write.bind(this), 1000); - else this.running = false; + 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(); } - getLogs(filter?: { service?: string; since?: number; until?: number; limit?: number }) { - const conditions: string[] = []; - const parameters: (string | number)[] = []; + 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); - 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(sql).all(...parameters); + this.database.delete(schema.logs).where(where).run(); } - 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(sql).run(parameters); - this.emit("clear", filter?.service); + close() { + // stop writing to the database + this.writeQueue.unsubscribe(); } } diff --git a/src/modules/notifications/notifications-manager.ts b/src/modules/notifications/notifications-manager.ts index eb6c2c5..829f87c 100644 --- a/src/modules/notifications/notifications-manager.ts +++ b/src/modules/notifications/notifications-manager.ts @@ -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 { } async setup() { - this.state = ( - await stateManager.getMutableState("notification-manager", { channels: [] }) - ).proxy; + this.state = stateManager.getMutableState("notification-manager", { + channels: [], + }); } addOrUpdateChannel(channel: NotificationChannel) { diff --git a/src/modules/queries/queries/config.ts b/src/modules/queries/queries/config.ts index 6b96b01..4531cb4 100644 --- a/src/modules/queries/queries/config.ts +++ b/src/modules/queries/queries/config.ts @@ -1,12 +1,4 @@ -import { Observable } from "rxjs"; - import { Query } from "../types.js"; import bakeryConfig, { BakeryConfig } from "../../../services/config.js"; -export const ConfigQuery: Query = () => - 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.data$; diff --git a/src/modules/queries/queries/logs.ts b/src/modules/queries/queries/logs.ts index 00395e8..da84552 100644 --- a/src/modules/queries/queries/logs.ts +++ b/src/modules/queries/queries/logs.ts @@ -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 = (args: { +export const LogsQuery: Query = (args: { service?: string; since?: number; until?: number; @@ -13,7 +14,7 @@ export const LogsQuery: Query = (args: { // get last 500 lines from(logStore.getLogs({ service: args.service, limit: 500 })), // subscribe to new logs - fromEvent(logStore, "log").pipe( + logStore.insert$.pipe( // only return logs that match args filter((entry) => { return !args?.service || entry.service === args.service; diff --git a/src/modules/queries/queries/services.ts b/src/modules/queries/queries/services.ts index 0ed6c7e..d017368 100644 --- a/src/modules/queries/queries/services.ts +++ b/src/modules/queries/queries/services.ts @@ -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 = () => - 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), + ), + ); diff --git a/src/modules/scrapper/index.ts b/src/modules/scrapper/index.ts index 75197b6..6ba1f62 100644 --- a/src/modules/scrapper/index.ts +++ b/src/modules/scrapper/index.ts @@ -37,7 +37,7 @@ export default class Scrapper extends EventEmitter { } async setup() { - this.state = (await this.app.state.getMutableState("scrapper", { pubkeys: [] })).proxy; + this.state = this.app.state.getMutableState("scrapper", { pubkeys: [] }); } async ensureData() { diff --git a/src/modules/scrapper/pubkey-scrapper.ts b/src/modules/scrapper/pubkey-scrapper.ts index f1d3a4d..c44bb4b 100644 --- a/src/modules/scrapper/pubkey-scrapper.ts +++ b/src/modules/scrapper/pubkey-scrapper.ts @@ -56,7 +56,7 @@ export default class PubkeyScrapper extends EventEmitter { `${this.pubkey}|${relay.url}`, {}, ); - if (state) scrapper.state = state.proxy; + if (state) scrapper.state = state; this.relayScrappers.set(url, scrapper); } diff --git a/src/modules/state/application-state-manager.ts b/src/modules/state/application-state-manager.ts deleted file mode 100644 index e785d28..0000000 --- a/src/modules/state/application-state-manager.ts +++ /dev/null @@ -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>(); - - database: Database; - constructor(database: Database) { - this.database = database; - } - - async setup() { - await migrations.run(this.database); - } - - async getMutableState(key: string, initialState: T) { - const cached = this.mutableState.get(key); - if (cached) return cached as MutableState; - - const state = new MutableState(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(); - } - } -} diff --git a/src/modules/state/mutable-state.ts b/src/modules/state/mutable-state.ts deleted file mode 100644 index f23302a..0000000 --- a/src/modules/state/mutable-state.ts +++ /dev/null @@ -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 = { - /** 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 extends EventEmitter> { - 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); - } -} diff --git a/src/services/app-state.ts b/src/services/app-state.ts new file mode 100644 index 0000000..5b450f0 --- /dev/null +++ b/src/services/app-state.ts @@ -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; diff --git a/src/services/bakery.ts b/src/services/bakery-signer.ts similarity index 100% rename from src/services/bakery.ts rename to src/services/bakery-signer.ts diff --git a/src/services/database.ts b/src/services/database.ts deleted file mode 100644 index fc064fc..0000000 --- a/src/services/database.ts +++ /dev/null @@ -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; diff --git a/src/services/event-cache.ts b/src/services/event-cache.ts index 20a228c..a7a5557 100644 --- a/src/services/event-cache.ts +++ b/src/services/event-cache.ts @@ -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; diff --git a/src/services/log-store.ts b/src/services/log-store.ts index 5b73ea6..1ae3a2c 100644 --- a/src/services/log-store.ts +++ b/src/services/log-store.ts @@ -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; diff --git a/src/services/mcp/tools/database.ts b/src/services/mcp/tools/database.ts index 2940fdb..c3677aa 100644 --- a/src/services/mcp/tools/database.ts +++ b/src/services/mcp/tools/database.ts @@ -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") }], }; }); diff --git a/src/services/state.ts b/src/services/state.ts deleted file mode 100644 index 8898df9..0000000 --- a/src/services/state.ts +++ /dev/null @@ -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; diff --git a/src/sqlite/event-store.ts b/src/sqlite/event-store.ts index 764dd62..9a0dbc8 100644 --- a/src/sqlite/event-store.ts +++ b/src/sqlite/event-store.ts @@ -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 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(`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 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) { - 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; - } - } + // 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); 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 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(); - const having = new Set(); - - let joins = new Set(); - 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 { 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(sql).all(parameters).map(parseEventRow); - } - - *iterateEventsForFilters(filters: Filter[]): IterableIterator { - const { sql, parameters } = this.buildSQLQueryForFilters(filters); - const iterator = this.db.prepare(sql).iterate(parameters); - - while (true) { - const { value: row, done } = iterator.next(); - if (done) break; - - yield parseEventRow(row); - } + return this.database.$client + .prepare(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(`SELECT COUNT(*) as count FROM ( ${sql} )`) + const results = this.database.$client + .prepare(`SELECT COUNT(*) as count FROM ( ${stmt} )`) .get(parameters) as { count: number } | undefined; return results?.count ?? 0; }