applesauce-sqlite 0.0.0-next-20250915145415
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/dist/better-sqlite3/event-database.d.ts +52 -0
- package/dist/better-sqlite3/event-database.js +104 -0
- package/dist/better-sqlite3/index.d.ts +2 -0
- package/dist/better-sqlite3/index.js +2 -0
- package/dist/better-sqlite3/methods.d.ts +31 -0
- package/dist/better-sqlite3/methods.js +149 -0
- package/dist/bun/event-database.d.ts +52 -0
- package/dist/bun/event-database.js +105 -0
- package/dist/bun/index.d.ts +2 -0
- package/dist/bun/index.js +2 -0
- package/dist/bun/methods.d.ts +31 -0
- package/dist/bun/methods.js +152 -0
- package/dist/helpers/index.d.ts +3 -0
- package/dist/helpers/index.js +3 -0
- package/dist/helpers/search.d.ts +16 -0
- package/dist/helpers/search.js +60 -0
- package/dist/helpers/sql.d.ts +15 -0
- package/dist/helpers/sql.js +122 -0
- package/dist/helpers/sqlite.d.ts +66 -0
- package/dist/helpers/sqlite.js +367 -0
- package/dist/helpers/statements.d.ts +47 -0
- package/dist/helpers/statements.js +65 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/libsql/event-database.d.ts +54 -0
- package/dist/libsql/event-database.js +106 -0
- package/dist/libsql/index.d.ts +2 -0
- package/dist/libsql/index.js +2 -0
- package/dist/libsql/methods.d.ts +31 -0
- package/dist/libsql/methods.js +249 -0
- package/dist/native/event-database.d.ts +52 -0
- package/dist/native/event-database.js +104 -0
- package/dist/native/index.d.ts +2 -0
- package/dist/native/index.js +2 -0
- package/dist/native/methods.d.ts +31 -0
- package/dist/native/methods.js +174 -0
- package/dist/relay.d.ts +1 -0
- package/dist/relay.js +166 -0
- package/dist/sqlite-event-database.d.ts +53 -0
- package/dist/sqlite-event-database.js +105 -0
- package/package.json +113 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 hzrd149
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# applesauce-sqlite
|
|
2
|
+
|
|
3
|
+
A SQLite3 event database implementation for AppleSauce, providing persistent storage for Nostr events. This package extends the core `applesauce-core` functionality by replacing the default in-memory event database with a persistent SQLite database.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- **Persistent Storage**: Store Nostr events in a SQLite database that persists between application restarts
|
|
8
|
+
- **Hybrid Architecture**: Combines in-memory caching with SQLite persistence for optimal performance
|
|
9
|
+
- **Full Compatibility**: Drop-in replacement for the default in-memory event database
|
|
10
|
+
- **Efficient Querying**: Optimized SQLite queries for filtering and retrieving Nostr events
|
|
11
|
+
- **Built-in Relay**: Includes a complete Nostr relay implementation using the SQLite database
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# For better-sqlite3
|
|
17
|
+
npm install applesauce-sqlite better-sqlite3
|
|
18
|
+
|
|
19
|
+
# For libsql
|
|
20
|
+
npm install applesauce-sqlite @libsql/client
|
|
21
|
+
|
|
22
|
+
# For bun / deno / native sqlite
|
|
23
|
+
bun add applesauce-sqlite
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Basic Usage
|
|
27
|
+
|
|
28
|
+
### Using an event database with EventStore
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import { EventStore } from "applesauce-core";
|
|
32
|
+
import { BetterSqlite3EventDatabase } from "applesauce-sqlite/better-sqlite3";
|
|
33
|
+
|
|
34
|
+
// Create a SQLite database (file-based or in-memory)
|
|
35
|
+
const database = new BetterSqlite3EventDatabase("./events.db"); // or ":memory:" for in-memory
|
|
36
|
+
|
|
37
|
+
// Create EventStore with SQLite backend
|
|
38
|
+
const eventStore = new EventStore(database);
|
|
39
|
+
|
|
40
|
+
// Use the event store as normal
|
|
41
|
+
eventStore.add(someNostrEvent);
|
|
42
|
+
|
|
43
|
+
// The events are now persisted to SQLite!
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### With Models and Subscriptions
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
import { EventStore } from "applesauce-core";
|
|
50
|
+
import { ProfileModel, TimelineModel } from "applesauce-core/models";
|
|
51
|
+
import { BetterSqlite3EventDatabase } from "applesauce-better-sqlite3";
|
|
52
|
+
import { Relay } from "nostr-tools/relay";
|
|
53
|
+
|
|
54
|
+
// Create persistent event store
|
|
55
|
+
const database = new BetterSqlite3EventDatabase("./events.db");
|
|
56
|
+
const eventStore = new EventStore(database);
|
|
57
|
+
|
|
58
|
+
// Connect to a relay and store events
|
|
59
|
+
const relay = await Relay.connect("wss://relay.example.com");
|
|
60
|
+
const sub = relay.subscribe([{ kinds: [0, 1] }], {
|
|
61
|
+
onevent(event) {
|
|
62
|
+
eventStore.add(event); // Events are automatically persisted
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Use models as normal - they'll work with persisted data
|
|
67
|
+
const profile = eventStore.model(ProfileModel, "npub...");
|
|
68
|
+
profile.subscribe((parsed) => {
|
|
69
|
+
console.log("Profile loaded from database:", parsed);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Timeline will include events from previous sessions
|
|
73
|
+
const timeline = eventStore.model(TimelineModel, { kinds: [1] });
|
|
74
|
+
timeline.subscribe((events) => {
|
|
75
|
+
console.log("Timeline with persisted events:", events.length);
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Advanced Usage
|
|
80
|
+
|
|
81
|
+
### Custom Relay Implementation
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
import { EventStore } from "applesauce-core";
|
|
85
|
+
import { BetterSqlite3EventDatabase } from "applesauce-better-sqlite3";
|
|
86
|
+
import { WebSocketServer } from "ws";
|
|
87
|
+
|
|
88
|
+
// Create your own relay with custom logic
|
|
89
|
+
const database = new BetterSqlite3EventDatabase("./custom-relay.db");
|
|
90
|
+
const eventStore = new EventStore(database);
|
|
91
|
+
|
|
92
|
+
const wss = new WebSocketServer({ port: 8080 });
|
|
93
|
+
|
|
94
|
+
wss.on("connection", (ws) => {
|
|
95
|
+
ws.on("message", (data) => {
|
|
96
|
+
const message = JSON.parse(data.toString());
|
|
97
|
+
|
|
98
|
+
if (message[0] === "EVENT") {
|
|
99
|
+
const event = message[1];
|
|
100
|
+
const added = eventStore.add(event);
|
|
101
|
+
|
|
102
|
+
if (added) {
|
|
103
|
+
ws.send(JSON.stringify(["OK", event.id, true, ""]));
|
|
104
|
+
// Broadcast to other clients...
|
|
105
|
+
} else {
|
|
106
|
+
ws.send(JSON.stringify(["OK", event.id, false, "rejected"]));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { IEventDatabase } from "applesauce-core";
|
|
2
|
+
import { Filter, NostrEvent } from "applesauce-core/helpers";
|
|
3
|
+
import { type Database as TDatabase } from "better-sqlite3";
|
|
4
|
+
import { SearchContentFormatter } from "../helpers/search.js";
|
|
5
|
+
/** Options for the {@link BetterSqlite3EventDatabase} */
|
|
6
|
+
export type BetterSqlite3EventDatabaseOptions = {
|
|
7
|
+
search?: boolean;
|
|
8
|
+
searchContentFormatter?: SearchContentFormatter;
|
|
9
|
+
};
|
|
10
|
+
export declare class BetterSqlite3EventDatabase implements IEventDatabase {
|
|
11
|
+
db: TDatabase;
|
|
12
|
+
/** If search is enabled */
|
|
13
|
+
private search;
|
|
14
|
+
/** The search content formatter */
|
|
15
|
+
private searchContentFormatter;
|
|
16
|
+
constructor(database?: string | TDatabase, options?: BetterSqlite3EventDatabaseOptions);
|
|
17
|
+
/** Store a Nostr event in the database */
|
|
18
|
+
add(event: NostrEvent): NostrEvent;
|
|
19
|
+
/** Delete an event by ID */
|
|
20
|
+
remove(id: string): boolean;
|
|
21
|
+
/** Checks if an event exists */
|
|
22
|
+
hasEvent(id: string): boolean;
|
|
23
|
+
/** Get an event by its ID */
|
|
24
|
+
getEvent(id: string): NostrEvent | undefined;
|
|
25
|
+
/** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
|
|
26
|
+
getReplaceable(kind: number, pubkey: string, identifier?: string): NostrEvent | undefined;
|
|
27
|
+
/** Checks if a replaceable event exists */
|
|
28
|
+
hasReplaceable(kind: number, pubkey: string, identifier?: string): boolean;
|
|
29
|
+
/** Returns all the versions of a replaceable event */
|
|
30
|
+
getReplaceableHistory(kind: number, pubkey: string, identifier?: string): NostrEvent[];
|
|
31
|
+
/** Get all events that match the filters (supports NIP-50 search field) */
|
|
32
|
+
getByFilters(filters: (Filter & {
|
|
33
|
+
search?: string;
|
|
34
|
+
}) | (Filter & {
|
|
35
|
+
search?: string;
|
|
36
|
+
})[]): Set<NostrEvent>;
|
|
37
|
+
/** Get a timeline of events that match the filters (returns array in chronological order, supports NIP-50 search) */
|
|
38
|
+
getTimeline(filters: (Filter & {
|
|
39
|
+
search?: string;
|
|
40
|
+
}) | (Filter & {
|
|
41
|
+
search?: string;
|
|
42
|
+
})[]): NostrEvent[];
|
|
43
|
+
/** Set the search content formatter */
|
|
44
|
+
setSearchContentFormatter(formatter: SearchContentFormatter): void;
|
|
45
|
+
/** Get the current search content formatter */
|
|
46
|
+
getSearchContentFormatter(): SearchContentFormatter;
|
|
47
|
+
/** Rebuild the search index for all events */
|
|
48
|
+
rebuildSearchIndex(): void;
|
|
49
|
+
/** Close the database connection */
|
|
50
|
+
close(): void;
|
|
51
|
+
[Symbol.dispose](): void;
|
|
52
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { logger } from "applesauce-core";
|
|
2
|
+
import { insertEventIntoDescendingList } from "applesauce-core/helpers";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { createTables, deleteEvent, getEvent, getEventsByFilters, getReplaceable, getReplaceableHistory, hasEvent, hasReplaceable, insertEvent, rebuildSearchIndex, } from "../better-sqlite3/methods.js";
|
|
5
|
+
import { enhancedSearchContentFormatter } from "../helpers/search.js";
|
|
6
|
+
const log = logger.extend("BetterSqlite3EventDatabase");
|
|
7
|
+
export class BetterSqlite3EventDatabase {
|
|
8
|
+
db;
|
|
9
|
+
/** If search is enabled */
|
|
10
|
+
search;
|
|
11
|
+
/** The search content formatter */
|
|
12
|
+
searchContentFormatter;
|
|
13
|
+
constructor(database = ":memory:", options) {
|
|
14
|
+
this.db = typeof database === "string" ? new Database(database) : database;
|
|
15
|
+
this.search = options?.search ?? false;
|
|
16
|
+
this.searchContentFormatter = options?.searchContentFormatter ?? enhancedSearchContentFormatter;
|
|
17
|
+
// Setup the database tables and indexes
|
|
18
|
+
createTables(this.db, this.search);
|
|
19
|
+
}
|
|
20
|
+
/** Store a Nostr event in the database */
|
|
21
|
+
add(event) {
|
|
22
|
+
try {
|
|
23
|
+
insertEvent(this.db, event, this.search ? this.searchContentFormatter : undefined);
|
|
24
|
+
return event;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
log("Error inserting event:", error);
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Delete an event by ID */
|
|
32
|
+
remove(id) {
|
|
33
|
+
try {
|
|
34
|
+
// Remove event from database
|
|
35
|
+
return deleteEvent(this.db, id);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Checks if an event exists */
|
|
42
|
+
hasEvent(id) {
|
|
43
|
+
return hasEvent(this.db, id);
|
|
44
|
+
}
|
|
45
|
+
/** Get an event by its ID */
|
|
46
|
+
getEvent(id) {
|
|
47
|
+
return getEvent(this.db, id);
|
|
48
|
+
}
|
|
49
|
+
/** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
|
|
50
|
+
getReplaceable(kind, pubkey, identifier = "") {
|
|
51
|
+
return getReplaceable(this.db, kind, pubkey, identifier);
|
|
52
|
+
}
|
|
53
|
+
/** Checks if a replaceable event exists */
|
|
54
|
+
hasReplaceable(kind, pubkey, identifier = "") {
|
|
55
|
+
return hasReplaceable(this.db, kind, pubkey, identifier);
|
|
56
|
+
}
|
|
57
|
+
/** Returns all the versions of a replaceable event */
|
|
58
|
+
getReplaceableHistory(kind, pubkey, identifier = "") {
|
|
59
|
+
return getReplaceableHistory(this.db, kind, pubkey, identifier);
|
|
60
|
+
}
|
|
61
|
+
/** Get all events that match the filters (supports NIP-50 search field) */
|
|
62
|
+
getByFilters(filters) {
|
|
63
|
+
try {
|
|
64
|
+
// If search is disabled, remove the search field from the filters
|
|
65
|
+
if (!this.search && (Array.isArray(filters) ? filters.some((f) => "search" in f) : "search" in filters))
|
|
66
|
+
throw new Error("Search is disabled");
|
|
67
|
+
return getEventsByFilters(this.db, filters);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return new Set();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Get a timeline of events that match the filters (returns array in chronological order, supports NIP-50 search) */
|
|
74
|
+
getTimeline(filters) {
|
|
75
|
+
const events = this.getByFilters(filters);
|
|
76
|
+
const timeline = [];
|
|
77
|
+
for (const event of events)
|
|
78
|
+
insertEventIntoDescendingList(timeline, event);
|
|
79
|
+
return timeline;
|
|
80
|
+
}
|
|
81
|
+
/** Set the search content formatter */
|
|
82
|
+
setSearchContentFormatter(formatter) {
|
|
83
|
+
this.searchContentFormatter = formatter;
|
|
84
|
+
}
|
|
85
|
+
/** Get the current search content formatter */
|
|
86
|
+
getSearchContentFormatter() {
|
|
87
|
+
return this.searchContentFormatter;
|
|
88
|
+
}
|
|
89
|
+
/** Rebuild the search index for all events */
|
|
90
|
+
rebuildSearchIndex() {
|
|
91
|
+
if (!this.search)
|
|
92
|
+
throw new Error("Search is disabled");
|
|
93
|
+
rebuildSearchIndex(this.db, this.searchContentFormatter);
|
|
94
|
+
log("Search index rebuilt successfully");
|
|
95
|
+
}
|
|
96
|
+
/** Close the database connection */
|
|
97
|
+
close() {
|
|
98
|
+
log("Closing database connection");
|
|
99
|
+
this.db.close();
|
|
100
|
+
}
|
|
101
|
+
[Symbol.dispose]() {
|
|
102
|
+
this.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Filter, NostrEvent } from "applesauce-core/helpers";
|
|
2
|
+
import { Database } from "better-sqlite3";
|
|
3
|
+
import { FilterWithSearch, SearchContentFormatter } from "../helpers/search.js";
|
|
4
|
+
/** Create and migrate the `events`, `event_tags`, and search tables */
|
|
5
|
+
export declare function createTables(db: Database, search?: boolean): void;
|
|
6
|
+
/** Inserts search content for an event */
|
|
7
|
+
export declare function insertSearchContent(db: Database, event: NostrEvent, contentFormatter: SearchContentFormatter): void;
|
|
8
|
+
/** Removes search content for an event */
|
|
9
|
+
export declare function deleteSearchContent(db: Database, eventId: string): void;
|
|
10
|
+
/** Inserts an event into the `events`, `event_tags`, and search tables of a database */
|
|
11
|
+
export declare function insertEvent(db: Database, event: NostrEvent, contentFormatter?: SearchContentFormatter): boolean;
|
|
12
|
+
/** Insert indexable tags for an event into the event_tags table */
|
|
13
|
+
export declare function insertEventTags(db: Database, event: NostrEvent): void;
|
|
14
|
+
/** Removes an event by id from the `events`, `event_tags`, and search tables of a database */
|
|
15
|
+
export declare function deleteEvent(db: Database, id: string): boolean;
|
|
16
|
+
/** Checks if an event exists */
|
|
17
|
+
export declare function hasEvent(db: Database, id: string): boolean;
|
|
18
|
+
/** Gets a single event from a database */
|
|
19
|
+
export declare function getEvent(db: Database, id: string): NostrEvent | undefined;
|
|
20
|
+
/** Gets the latest replaceable event from a database */
|
|
21
|
+
export declare function getReplaceable(db: Database, kind: number, pubkey: string, identifier: string): NostrEvent | undefined;
|
|
22
|
+
/** Gets the history of a replaceable event from a database */
|
|
23
|
+
export declare function getReplaceableHistory(db: Database, kind: number, pubkey: string, identifier: string): NostrEvent[];
|
|
24
|
+
/** Checks if a replaceable event exists in a database */
|
|
25
|
+
export declare function hasReplaceable(db: Database, kind: number, pubkey: string, identifier?: string): boolean;
|
|
26
|
+
/** Get all events that match the filters (includes NIP-50 search support) */
|
|
27
|
+
export declare function getEventsByFilters(db: Database, filters: FilterWithSearch | FilterWithSearch[]): Set<NostrEvent>;
|
|
28
|
+
/** Search events using FTS5 full-text search (convenience wrapper around getEventsByFilters) */
|
|
29
|
+
export declare function searchEvents(db: Database, search: string, options?: Filter): NostrEvent[];
|
|
30
|
+
/** Rebuild the FTS5 search index for all events */
|
|
31
|
+
export declare function rebuildSearchIndex(db: Database, contentFormatter: SearchContentFormatter): void;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { getIndexableTags, getReplaceableIdentifier } from "applesauce-core/helpers";
|
|
2
|
+
import { CREATE_SEARCH_TABLE_STATEMENT, DELETE_SEARCH_CONTENT_STATEMENT, INSERT_SEARCH_CONTENT_STATEMENT, } from "../helpers/search.js";
|
|
3
|
+
import { buildFiltersQuery, rowToEvent } from "../helpers/sql.js";
|
|
4
|
+
import { CREATE_EVENT_TAGS_TABLE_STATEMENT, CREATE_EVENTS_TABLE_STATEMENT, CREATE_INDEXES_STATEMENTS, DELETE_EVENT_STATEMENT, DELETE_EVENT_TAGS_STATEMENT, GET_ALL_EVENTS_STATEMENT, GET_EVENT_STATEMENT, GET_REPLACEABLE_HISTORY_STATEMENT, GET_REPLACEABLE_STATEMENT, HAS_EVENT_STATEMENT, HAS_REPLACEABLE_STATEMENT, INSERT_EVENT_STATEMENT, INSERT_EVENT_TAG_STATEMENT, } from "../helpers/statements.js";
|
|
5
|
+
/** Create and migrate the `events`, `event_tags`, and search tables */
|
|
6
|
+
export function createTables(db, search = true) {
|
|
7
|
+
// Create the events table
|
|
8
|
+
db.exec(CREATE_EVENTS_TABLE_STATEMENT.sql);
|
|
9
|
+
// Create the event_tags table
|
|
10
|
+
db.exec(CREATE_EVENT_TAGS_TABLE_STATEMENT.sql);
|
|
11
|
+
// Create the FTS5 search table
|
|
12
|
+
if (search) {
|
|
13
|
+
db.exec(CREATE_SEARCH_TABLE_STATEMENT.sql);
|
|
14
|
+
}
|
|
15
|
+
// Create indexes
|
|
16
|
+
CREATE_INDEXES_STATEMENTS.forEach((indexStatement) => {
|
|
17
|
+
db.exec(indexStatement.sql);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Inserts search content for an event */
|
|
21
|
+
export function insertSearchContent(db, event, contentFormatter) {
|
|
22
|
+
const searchableContent = contentFormatter(event);
|
|
23
|
+
// Insert/update directly into the FTS5 table
|
|
24
|
+
const stmt = db.prepare(INSERT_SEARCH_CONTENT_STATEMENT.sql);
|
|
25
|
+
stmt.run(event.id, searchableContent, event.kind, event.pubkey, event.created_at);
|
|
26
|
+
}
|
|
27
|
+
/** Removes search content for an event */
|
|
28
|
+
export function deleteSearchContent(db, eventId) {
|
|
29
|
+
const stmt = db.prepare(DELETE_SEARCH_CONTENT_STATEMENT.sql);
|
|
30
|
+
stmt.run(eventId);
|
|
31
|
+
}
|
|
32
|
+
/** Inserts an event into the `events`, `event_tags`, and search tables of a database */
|
|
33
|
+
export function insertEvent(db, event, contentFormatter) {
|
|
34
|
+
const identifier = getReplaceableIdentifier(event);
|
|
35
|
+
return db.transaction(() => {
|
|
36
|
+
// Insert/update the main event
|
|
37
|
+
const stmt = db.prepare(INSERT_EVENT_STATEMENT.sql);
|
|
38
|
+
const result = stmt.run(event.id, event.kind, event.pubkey, event.created_at, event.content, JSON.stringify(event.tags), event.sig, identifier);
|
|
39
|
+
// Insert indexable tags into the event_tags table
|
|
40
|
+
insertEventTags(db, event);
|
|
41
|
+
// Insert searchable content into the search tables
|
|
42
|
+
if (contentFormatter)
|
|
43
|
+
insertSearchContent(db, event, contentFormatter);
|
|
44
|
+
return result.changes > 0;
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
/** Insert indexable tags for an event into the event_tags table */
|
|
48
|
+
export function insertEventTags(db, event) {
|
|
49
|
+
// Clear existing tags for this event first
|
|
50
|
+
const deleteStmt = db.prepare(DELETE_EVENT_TAGS_STATEMENT.sql);
|
|
51
|
+
deleteStmt.run(event.id);
|
|
52
|
+
// Get only the indexable tags using applesauce-core helper
|
|
53
|
+
const indexableTags = getIndexableTags(event);
|
|
54
|
+
if (indexableTags && indexableTags.size > 0) {
|
|
55
|
+
const insertStmt = db.prepare(INSERT_EVENT_TAG_STATEMENT.sql);
|
|
56
|
+
for (const tagString of indexableTags) {
|
|
57
|
+
// Parse the "tagName:tagValue" format
|
|
58
|
+
const [name, value] = tagString.split(":");
|
|
59
|
+
if (name && value)
|
|
60
|
+
insertStmt.run(event.id, name, value);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Removes an event by id from the `events`, `event_tags`, and search tables of a database */
|
|
65
|
+
export function deleteEvent(db, id) {
|
|
66
|
+
return db.transaction(() => {
|
|
67
|
+
// Delete from event_tags first (foreign key constraint)
|
|
68
|
+
const deleteTagsStmt = db.prepare(DELETE_EVENT_TAGS_STATEMENT.sql);
|
|
69
|
+
deleteTagsStmt.run(id);
|
|
70
|
+
// Delete from search tables
|
|
71
|
+
deleteSearchContent(db, id);
|
|
72
|
+
// Delete from events table
|
|
73
|
+
const deleteEventStmt = db.prepare(DELETE_EVENT_STATEMENT.sql);
|
|
74
|
+
const result = deleteEventStmt.run(id);
|
|
75
|
+
return result.changes > 0;
|
|
76
|
+
})();
|
|
77
|
+
}
|
|
78
|
+
/** Checks if an event exists */
|
|
79
|
+
export function hasEvent(db, id) {
|
|
80
|
+
const stmt = db.prepare(HAS_EVENT_STATEMENT.sql);
|
|
81
|
+
const result = stmt.get(id);
|
|
82
|
+
if (!result)
|
|
83
|
+
return false;
|
|
84
|
+
return result.count > 0;
|
|
85
|
+
}
|
|
86
|
+
/** Gets a single event from a database */
|
|
87
|
+
export function getEvent(db, id) {
|
|
88
|
+
const stmt = db.prepare(GET_EVENT_STATEMENT.sql);
|
|
89
|
+
const row = stmt.get(id);
|
|
90
|
+
return row && rowToEvent(row);
|
|
91
|
+
}
|
|
92
|
+
/** Gets the latest replaceable event from a database */
|
|
93
|
+
export function getReplaceable(db, kind, pubkey, identifier) {
|
|
94
|
+
const stmt = db.prepare(GET_REPLACEABLE_STATEMENT.sql);
|
|
95
|
+
const row = stmt.get(kind, pubkey, identifier);
|
|
96
|
+
return row && rowToEvent(row);
|
|
97
|
+
}
|
|
98
|
+
/** Gets the history of a replaceable event from a database */
|
|
99
|
+
export function getReplaceableHistory(db, kind, pubkey, identifier) {
|
|
100
|
+
const stmt = db.prepare(GET_REPLACEABLE_HISTORY_STATEMENT.sql);
|
|
101
|
+
return stmt.all(kind, pubkey, identifier).map(rowToEvent);
|
|
102
|
+
}
|
|
103
|
+
/** Checks if a replaceable event exists in a database */
|
|
104
|
+
export function hasReplaceable(db, kind, pubkey, identifier = "") {
|
|
105
|
+
const stmt = db.prepare(HAS_REPLACEABLE_STATEMENT.sql);
|
|
106
|
+
const result = stmt.get(kind, pubkey, identifier);
|
|
107
|
+
if (!result)
|
|
108
|
+
return false;
|
|
109
|
+
return result.count > 0;
|
|
110
|
+
}
|
|
111
|
+
/** Get all events that match the filters (includes NIP-50 search support) */
|
|
112
|
+
export function getEventsByFilters(db, filters) {
|
|
113
|
+
const query = buildFiltersQuery(filters);
|
|
114
|
+
if (!query)
|
|
115
|
+
return new Set();
|
|
116
|
+
const eventSet = new Set();
|
|
117
|
+
const stmt = db.prepare(query.sql);
|
|
118
|
+
const rows = stmt.all(...query.params);
|
|
119
|
+
// Convert rows to events and add to set
|
|
120
|
+
for (const row of rows)
|
|
121
|
+
eventSet.add(rowToEvent(row));
|
|
122
|
+
return eventSet;
|
|
123
|
+
}
|
|
124
|
+
/** Search events using FTS5 full-text search (convenience wrapper around getEventsByFilters) */
|
|
125
|
+
export function searchEvents(db, search, options) {
|
|
126
|
+
if (!search.trim())
|
|
127
|
+
return [];
|
|
128
|
+
// Build filter with search and other options
|
|
129
|
+
const filter = {
|
|
130
|
+
search: search.trim(),
|
|
131
|
+
...options,
|
|
132
|
+
};
|
|
133
|
+
// Use the main filter system which now supports search
|
|
134
|
+
const results = getEventsByFilters(db, filter);
|
|
135
|
+
return Array.from(results);
|
|
136
|
+
}
|
|
137
|
+
/** Rebuild the FTS5 search index for all events */
|
|
138
|
+
export function rebuildSearchIndex(db, contentFormatter) {
|
|
139
|
+
db.transaction(() => {
|
|
140
|
+
// Clear existing search data
|
|
141
|
+
db.exec(`DELETE FROM events_search;`);
|
|
142
|
+
// Rebuild from all events
|
|
143
|
+
const stmt = db.prepare(GET_ALL_EVENTS_STATEMENT.sql);
|
|
144
|
+
const events = stmt.all().map(rowToEvent);
|
|
145
|
+
for (const event of events) {
|
|
146
|
+
insertSearchContent(db, event, contentFormatter);
|
|
147
|
+
}
|
|
148
|
+
})();
|
|
149
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { IEventDatabase } from "applesauce-core";
|
|
2
|
+
import { Filter, NostrEvent } from "applesauce-core/helpers";
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { SearchContentFormatter } from "../helpers/search.js";
|
|
5
|
+
/** Options for the {@link BunSqliteEventDatabase} */
|
|
6
|
+
export type BunSqliteEventDatabaseOptions = {
|
|
7
|
+
search?: boolean;
|
|
8
|
+
searchContentFormatter?: SearchContentFormatter;
|
|
9
|
+
};
|
|
10
|
+
export declare class BunSqliteEventDatabase implements IEventDatabase {
|
|
11
|
+
db: Database;
|
|
12
|
+
/** If search is enabled */
|
|
13
|
+
private search;
|
|
14
|
+
/** The search content formatter */
|
|
15
|
+
private searchContentFormatter;
|
|
16
|
+
constructor(database?: string | Database, options?: BunSqliteEventDatabaseOptions);
|
|
17
|
+
/** Store a Nostr event in the database */
|
|
18
|
+
add(event: NostrEvent): NostrEvent;
|
|
19
|
+
/** Delete an event by ID */
|
|
20
|
+
remove(id: string): boolean;
|
|
21
|
+
/** Checks if an event exists */
|
|
22
|
+
hasEvent(id: string): boolean;
|
|
23
|
+
/** Get an event by its ID */
|
|
24
|
+
getEvent(id: string): NostrEvent | undefined;
|
|
25
|
+
/** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
|
|
26
|
+
getReplaceable(kind: number, pubkey: string, identifier?: string): NostrEvent | undefined;
|
|
27
|
+
/** Checks if a replaceable event exists */
|
|
28
|
+
hasReplaceable(kind: number, pubkey: string, identifier?: string): boolean;
|
|
29
|
+
/** Returns all the versions of a replaceable event */
|
|
30
|
+
getReplaceableHistory(kind: number, pubkey: string, identifier?: string): NostrEvent[];
|
|
31
|
+
/** Get all events that match the filters (supports NIP-50 search field) */
|
|
32
|
+
getByFilters(filters: (Filter & {
|
|
33
|
+
search?: string;
|
|
34
|
+
}) | (Filter & {
|
|
35
|
+
search?: string;
|
|
36
|
+
})[]): Set<NostrEvent>;
|
|
37
|
+
/** Get a timeline of events that match the filters (returns array in chronological order, supports NIP-50 search) */
|
|
38
|
+
getTimeline(filters: (Filter & {
|
|
39
|
+
search?: string;
|
|
40
|
+
}) | (Filter & {
|
|
41
|
+
search?: string;
|
|
42
|
+
})[]): NostrEvent[];
|
|
43
|
+
/** Set the search content formatter */
|
|
44
|
+
setSearchContentFormatter(formatter: SearchContentFormatter): void;
|
|
45
|
+
/** Get the current search content formatter */
|
|
46
|
+
getSearchContentFormatter(): SearchContentFormatter;
|
|
47
|
+
/** Rebuild the search index for all events */
|
|
48
|
+
rebuildSearchIndex(): void;
|
|
49
|
+
/** Close the database connection */
|
|
50
|
+
close(): void;
|
|
51
|
+
[Symbol.dispose](): void;
|
|
52
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { logger } from "applesauce-core";
|
|
2
|
+
import { insertEventIntoDescendingList } from "applesauce-core/helpers";
|
|
3
|
+
// @ts-ignore - bun:sqlite is a built-in module in Bun
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { createTables, deleteEvent, getEvent, getEventsByFilters, getReplaceable, getReplaceableHistory, hasEvent, hasReplaceable, insertEvent, rebuildSearchIndex, } from "./methods.js";
|
|
6
|
+
import { enhancedSearchContentFormatter } from "../helpers/search.js";
|
|
7
|
+
const log = logger.extend("BunSqliteEventDatabase");
|
|
8
|
+
export class BunSqliteEventDatabase {
|
|
9
|
+
db;
|
|
10
|
+
/** If search is enabled */
|
|
11
|
+
search;
|
|
12
|
+
/** The search content formatter */
|
|
13
|
+
searchContentFormatter;
|
|
14
|
+
constructor(database = ":memory:", options) {
|
|
15
|
+
this.db = typeof database === "string" ? new Database(database) : database;
|
|
16
|
+
this.search = options?.search ?? false;
|
|
17
|
+
this.searchContentFormatter = options?.searchContentFormatter ?? enhancedSearchContentFormatter;
|
|
18
|
+
// Setup the database tables and indexes
|
|
19
|
+
createTables(this.db, this.search);
|
|
20
|
+
}
|
|
21
|
+
/** Store a Nostr event in the database */
|
|
22
|
+
add(event) {
|
|
23
|
+
try {
|
|
24
|
+
insertEvent(this.db, event, this.search ? this.searchContentFormatter : undefined);
|
|
25
|
+
return event;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
log("Error inserting event:", error);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Delete an event by ID */
|
|
33
|
+
remove(id) {
|
|
34
|
+
try {
|
|
35
|
+
// Remove event from database
|
|
36
|
+
return deleteEvent(this.db, id);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Checks if an event exists */
|
|
43
|
+
hasEvent(id) {
|
|
44
|
+
return hasEvent(this.db, id);
|
|
45
|
+
}
|
|
46
|
+
/** Get an event by its ID */
|
|
47
|
+
getEvent(id) {
|
|
48
|
+
return getEvent(this.db, id);
|
|
49
|
+
}
|
|
50
|
+
/** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
|
|
51
|
+
getReplaceable(kind, pubkey, identifier = "") {
|
|
52
|
+
return getReplaceable(this.db, kind, pubkey, identifier);
|
|
53
|
+
}
|
|
54
|
+
/** Checks if a replaceable event exists */
|
|
55
|
+
hasReplaceable(kind, pubkey, identifier = "") {
|
|
56
|
+
return hasReplaceable(this.db, kind, pubkey, identifier);
|
|
57
|
+
}
|
|
58
|
+
/** Returns all the versions of a replaceable event */
|
|
59
|
+
getReplaceableHistory(kind, pubkey, identifier = "") {
|
|
60
|
+
return getReplaceableHistory(this.db, kind, pubkey, identifier);
|
|
61
|
+
}
|
|
62
|
+
/** Get all events that match the filters (supports NIP-50 search field) */
|
|
63
|
+
getByFilters(filters) {
|
|
64
|
+
try {
|
|
65
|
+
// If search is disabled, remove the search field from the filters
|
|
66
|
+
if (!this.search && (Array.isArray(filters) ? filters.some((f) => "search" in f) : "search" in filters))
|
|
67
|
+
throw new Error("Search is disabled");
|
|
68
|
+
return getEventsByFilters(this.db, filters);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return new Set();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Get a timeline of events that match the filters (returns array in chronological order, supports NIP-50 search) */
|
|
75
|
+
getTimeline(filters) {
|
|
76
|
+
const events = this.getByFilters(filters);
|
|
77
|
+
const timeline = [];
|
|
78
|
+
for (const event of events)
|
|
79
|
+
insertEventIntoDescendingList(timeline, event);
|
|
80
|
+
return timeline;
|
|
81
|
+
}
|
|
82
|
+
/** Set the search content formatter */
|
|
83
|
+
setSearchContentFormatter(formatter) {
|
|
84
|
+
this.searchContentFormatter = formatter;
|
|
85
|
+
}
|
|
86
|
+
/** Get the current search content formatter */
|
|
87
|
+
getSearchContentFormatter() {
|
|
88
|
+
return this.searchContentFormatter;
|
|
89
|
+
}
|
|
90
|
+
/** Rebuild the search index for all events */
|
|
91
|
+
rebuildSearchIndex() {
|
|
92
|
+
if (!this.search)
|
|
93
|
+
throw new Error("Search is disabled");
|
|
94
|
+
rebuildSearchIndex(this.db, this.searchContentFormatter);
|
|
95
|
+
log("Search index rebuilt successfully");
|
|
96
|
+
}
|
|
97
|
+
/** Close the database connection */
|
|
98
|
+
close() {
|
|
99
|
+
log("Closing database connection");
|
|
100
|
+
this.db.close();
|
|
101
|
+
}
|
|
102
|
+
[Symbol.dispose]() {
|
|
103
|
+
this.close();
|
|
104
|
+
}
|
|
105
|
+
}
|