applesauce-sqlite 0.0.0-next-20250930093922 → 0.0.0-next-20251117143754

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/README.md CHANGED
@@ -64,7 +64,7 @@ const sub = relay.subscribe([{ kinds: [0, 1] }], {
64
64
  });
65
65
 
66
66
  // Use models as normal - they'll work with persisted data
67
- const profile = eventStore.model(ProfileModel, "npub...");
67
+ const profile = eventStore.model(ProfileModel, "pubkey...");
68
68
  profile.subscribe((parsed) => {
69
69
  console.log("Profile loaded from database:", parsed);
70
70
  });
@@ -18,11 +18,13 @@ export declare class BetterSqlite3EventDatabase implements IEventDatabase {
18
18
  add(event: NostrEvent): NostrEvent;
19
19
  /** Delete an event by ID */
20
20
  remove(id: string): boolean;
21
+ /** Remove multiple events that match the given filters */
22
+ removeByFilters(filters: FilterWithSearch | FilterWithSearch[]): number;
21
23
  /** Checks if an event exists */
22
24
  hasEvent(id: string): boolean;
23
25
  /** Get an event by its ID */
24
26
  getEvent(id: string): NostrEvent | undefined;
25
- /** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
27
+ /** Get the latest replaceable event For replaceable events (10000-19999 and 30000-39999), returns the most recent event */
26
28
  getReplaceable(kind: number, pubkey: string, identifier?: string): NostrEvent | undefined;
27
29
  /** Checks if a replaceable event exists */
28
30
  hasReplaceable(kind: number, pubkey: string, identifier?: string): boolean;
@@ -1,6 +1,6 @@
1
1
  import { logger } from "applesauce-core";
2
2
  import Database from "better-sqlite3";
3
- import { createTables, deleteEvent, getEvent, getEventsByFilters, getReplaceable, getReplaceableHistory, hasEvent, hasReplaceable, insertEvent, rebuildSearchIndex, } from "../better-sqlite3/methods.js";
3
+ import { createTables, deleteEvent, deleteEventsByFilters, getEvent, getEventsByFilters, getReplaceable, getReplaceableHistory, hasEvent, hasReplaceable, insertEvent, rebuildSearchIndex, } from "../better-sqlite3/methods.js";
4
4
  import { enhancedSearchContentFormatter } from "../helpers/search.js";
5
5
  const log = logger.extend("BetterSqlite3EventDatabase");
6
6
  export class BetterSqlite3EventDatabase {
@@ -37,6 +37,13 @@ export class BetterSqlite3EventDatabase {
37
37
  return false;
38
38
  }
39
39
  }
40
+ /** Remove multiple events that match the given filters */
41
+ removeByFilters(filters) {
42
+ // If search is disabled, remove the search field from the filters
43
+ if (this.search && (Array.isArray(filters) ? filters.some((f) => "search" in f) : "search" in filters))
44
+ throw new Error("Cannot delete with search");
45
+ return deleteEventsByFilters(this.db, filters);
46
+ }
40
47
  /** Checks if an event exists */
41
48
  hasEvent(id) {
42
49
  return hasEvent(this.db, id);
@@ -45,7 +52,7 @@ export class BetterSqlite3EventDatabase {
45
52
  getEvent(id) {
46
53
  return getEvent(this.db, id);
47
54
  }
48
- /** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
55
+ /** Get the latest replaceable event For replaceable events (10000-19999 and 30000-39999), returns the most recent event */
49
56
  getReplaceable(kind, pubkey, identifier = "") {
50
57
  return getReplaceable(this.db, kind, pubkey, identifier);
51
58
  }
@@ -9,8 +9,6 @@ export declare function insertSearchContent(db: Database, event: NostrEvent, con
9
9
  export declare function deleteSearchContent(db: Database, eventId: string): void;
10
10
  /** Inserts an event into the `events`, `event_tags`, and search tables of a database */
11
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
12
  /** Removes an event by id from the `events`, `event_tags`, and search tables of a database */
15
13
  export declare function deleteEvent(db: Database, id: string): boolean;
16
14
  /** Checks if an event exists */
@@ -29,3 +27,5 @@ export declare function getEventsByFilters(db: Database, filters: FilterWithSear
29
27
  export declare function searchEvents(db: Database, search: string, options?: Filter): NostrEvent[];
30
28
  /** Rebuild the FTS5 search index for all events */
31
29
  export declare function rebuildSearchIndex(db: Database, contentFormatter: SearchContentFormatter): void;
30
+ /** Removes multiple events that match the given filters from the database */
31
+ export declare function deleteEventsByFilters(db: Database, filters: FilterWithSearch | FilterWithSearch[]): number;
@@ -1,7 +1,7 @@
1
1
  import { getIndexableTags, getReplaceableIdentifier } from "applesauce-core/helpers";
2
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";
3
+ import { buildFiltersQuery, buildDeleteFiltersQuery, rowToEvent } from "../helpers/sql.js";
4
+ import { CREATE_EVENT_TAGS_TABLE_STATEMENT, CREATE_EVENTS_TABLE_STATEMENT, CREATE_INDEXES_STATEMENTS, DELETE_EVENT_STATEMENT, GET_ALL_EVENTS_STATEMENT, GET_EVENT_STATEMENT, GET_REPLACEABLE_HISTORY_STATEMENT, GET_REPLACEABLE_STATEMENT, HAS_EVENT_STATEMENT, HAS_REPLACEABLE_STATEMENT, INSERT_EVENT_STATEMENT_WITH_IGNORE, INSERT_EVENT_TAG_STATEMENT, } from "../helpers/statements.js";
5
5
  /** Create and migrate the `events`, `event_tags`, and search tables */
6
6
  export function createTables(db, search = true) {
7
7
  // Create the events table
@@ -33,77 +33,85 @@ export function deleteSearchContent(db, eventId) {
33
33
  export function insertEvent(db, event, contentFormatter) {
34
34
  const identifier = getReplaceableIdentifier(event);
35
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);
36
+ // Try to insert the main event with OR IGNORE
37
+ const result = db
38
+ .prepare(INSERT_EVENT_STATEMENT_WITH_IGNORE.sql)
39
+ .run(event.id, event.kind, event.pubkey, event.created_at, event.content, JSON.stringify(event.tags), event.sig, identifier);
40
+ // If no rows were changed, the event already existed
41
+ if (result.changes === 0)
42
+ return false; // Event already exists, skip tags/search processing
43
+ // Event was inserted, continue with tags and search content
44
+ const indexableTags = getIndexableTags(event);
45
+ if (indexableTags && indexableTags.size > 0) {
46
+ const insertStmt = db.prepare(INSERT_EVENT_TAG_STATEMENT.sql);
47
+ for (const tagString of indexableTags) {
48
+ // Parse the "tagName:tagValue" format
49
+ const [name, value] = tagString.split(":");
50
+ if (name && value)
51
+ insertStmt.run(event.id, name, value);
52
+ }
61
53
  }
62
- }
54
+ if (contentFormatter) {
55
+ try {
56
+ insertSearchContent(db, event, contentFormatter);
57
+ }
58
+ catch (error) {
59
+ // Search table might not exist if search is disabled, ignore the error
60
+ }
61
+ }
62
+ return true;
63
+ })();
63
64
  }
64
65
  /** Removes an event by id from the `events`, `event_tags`, and search tables of a database */
65
66
  export function deleteEvent(db, id) {
66
67
  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);
68
+ // Delete from search tables if they exist
69
+ try {
70
+ deleteSearchContent(db, id);
71
+ }
72
+ catch (error) {
73
+ // Search table might not exist if search is disabled, ignore the error
74
+ }
75
+ // Delete from events table - this will CASCADE to event_tags automatically!
76
+ // The foreign key constraint: FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
77
+ // ensures that all related event_tags records are deleted automatically
78
+ const result = db.prepare(DELETE_EVENT_STATEMENT.sql).run(id);
75
79
  return result.changes > 0;
76
80
  })();
77
81
  }
78
82
  /** Checks if an event exists */
79
83
  export function hasEvent(db, id) {
80
- const stmt = db.prepare(HAS_EVENT_STATEMENT.sql);
81
- const result = stmt.get(id);
84
+ const result = db
85
+ .prepare(HAS_EVENT_STATEMENT.sql)
86
+ .get(id);
82
87
  if (!result)
83
88
  return false;
84
89
  return result.count > 0;
85
90
  }
86
91
  /** Gets a single event from a database */
87
92
  export function getEvent(db, id) {
88
- const stmt = db.prepare(GET_EVENT_STATEMENT.sql);
89
- const row = stmt.get(id);
93
+ const row = db.prepare(GET_EVENT_STATEMENT.sql).get(id);
90
94
  return row && rowToEvent(row);
91
95
  }
92
96
  /** Gets the latest replaceable event from a database */
93
97
  export function getReplaceable(db, kind, pubkey, identifier) {
94
- const stmt = db.prepare(GET_REPLACEABLE_STATEMENT.sql);
95
- const row = stmt.get(kind, pubkey, identifier);
98
+ const row = db
99
+ .prepare(GET_REPLACEABLE_STATEMENT.sql)
100
+ .get(kind, pubkey, identifier);
96
101
  return row && rowToEvent(row);
97
102
  }
98
103
  /** Gets the history of a replaceable event from a database */
99
104
  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);
105
+ return db
106
+ .prepare(GET_REPLACEABLE_HISTORY_STATEMENT.sql)
107
+ .all(kind, pubkey, identifier)
108
+ .map(rowToEvent);
102
109
  }
103
110
  /** Checks if a replaceable event exists in a database */
104
111
  export function hasReplaceable(db, kind, pubkey, identifier = "") {
105
- const stmt = db.prepare(HAS_REPLACEABLE_STATEMENT.sql);
106
- const result = stmt.get(kind, pubkey, identifier);
112
+ const result = db
113
+ .prepare(HAS_REPLACEABLE_STATEMENT.sql)
114
+ .get(kind, pubkey, identifier);
107
115
  if (!result)
108
116
  return false;
109
117
  return result.count > 0;
@@ -113,8 +121,7 @@ export function getEventsByFilters(db, filters) {
113
121
  const query = buildFiltersQuery(filters);
114
122
  if (!query)
115
123
  return [];
116
- const stmt = db.prepare(query.sql);
117
- const rows = stmt.all(...query.params);
124
+ const rows = db.prepare(query.sql).all(...query.params);
118
125
  // Convert rows to events and add to set
119
126
  return rows.map(rowToEvent);
120
127
  }
@@ -136,10 +143,34 @@ export function rebuildSearchIndex(db, contentFormatter) {
136
143
  // Clear existing search data
137
144
  db.exec(`DELETE FROM events_search;`);
138
145
  // Rebuild from all events
139
- const stmt = db.prepare(GET_ALL_EVENTS_STATEMENT.sql);
140
- const events = stmt.all().map(rowToEvent);
146
+ const events = db
147
+ .prepare(GET_ALL_EVENTS_STATEMENT.sql)
148
+ .all()
149
+ .map(rowToEvent);
141
150
  for (const event of events) {
142
151
  insertSearchContent(db, event, contentFormatter);
143
152
  }
144
153
  })();
145
154
  }
155
+ /** Removes multiple events that match the given filters from the database */
156
+ export function deleteEventsByFilters(db, filters) {
157
+ const whereClause = buildDeleteFiltersQuery(filters);
158
+ if (!whereClause)
159
+ return 0;
160
+ return db.transaction(() => {
161
+ // Delete from search tables if they exist
162
+ try {
163
+ const searchDeleteQuery = `DELETE FROM search_content WHERE event_id IN (SELECT id FROM events ${whereClause.sql})`;
164
+ db.prepare(searchDeleteQuery).run(...whereClause.params);
165
+ }
166
+ catch (error) {
167
+ // Search table might not exist if search is disabled, ignore the error
168
+ }
169
+ // Delete from events table - this will CASCADE to event_tags automatically!
170
+ // The foreign key constraint: FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
171
+ // ensures that all related event_tags records are deleted automatically
172
+ const deleteEventsQuery = `DELETE FROM events ${whereClause.sql}`;
173
+ const result = db.prepare(deleteEventsQuery).run(...whereClause.params);
174
+ return result.changes;
175
+ })();
176
+ }
@@ -18,11 +18,13 @@ export declare class BunSqliteEventDatabase implements IEventDatabase {
18
18
  add(event: NostrEvent): NostrEvent;
19
19
  /** Delete an event by ID */
20
20
  remove(id: string): boolean;
21
+ /** Remove multiple events that match the given filters */
22
+ removeByFilters(filters: FilterWithSearch | FilterWithSearch[]): number;
21
23
  /** Checks if an event exists */
22
24
  hasEvent(id: string): boolean;
23
25
  /** Get an event by its ID */
24
26
  getEvent(id: string): NostrEvent | undefined;
25
- /** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
27
+ /** Get the latest replaceable event For replaceable events (10000-19999 and 30000-39999), returns the most recent event */
26
28
  getReplaceable(kind: number, pubkey: string, identifier?: string): NostrEvent | undefined;
27
29
  /** Checks if a replaceable event exists */
28
30
  hasReplaceable(kind: number, pubkey: string, identifier?: string): boolean;
@@ -2,7 +2,7 @@ import { logger } from "applesauce-core";
2
2
  // @ts-ignore - bun:sqlite is a built-in module in Bun
3
3
  import { Database } from "bun:sqlite";
4
4
  import { enhancedSearchContentFormatter } from "../helpers/search.js";
5
- import { createTables, deleteEvent, getEvent, getEventsByFilters, getReplaceable, getReplaceableHistory, hasEvent, hasReplaceable, insertEvent, rebuildSearchIndex, } from "./methods.js";
5
+ import { createTables, deleteEvent, deleteEventsByFilters, getEvent, getEventsByFilters, getReplaceable, getReplaceableHistory, hasEvent, hasReplaceable, insertEvent, rebuildSearchIndex, } from "./methods.js";
6
6
  const log = logger.extend("BunSqliteEventDatabase");
7
7
  export class BunSqliteEventDatabase {
8
8
  db;
@@ -38,6 +38,13 @@ export class BunSqliteEventDatabase {
38
38
  return false;
39
39
  }
40
40
  }
41
+ /** Remove multiple events that match the given filters */
42
+ removeByFilters(filters) {
43
+ // If search is disabled, remove the search field from the filters
44
+ if (this.search && (Array.isArray(filters) ? filters.some((f) => "search" in f) : "search" in filters))
45
+ throw new Error("Cannot delete with search");
46
+ return deleteEventsByFilters(this.db, filters);
47
+ }
41
48
  /** Checks if an event exists */
42
49
  hasEvent(id) {
43
50
  return hasEvent(this.db, id);
@@ -46,7 +53,7 @@ export class BunSqliteEventDatabase {
46
53
  getEvent(id) {
47
54
  return getEvent(this.db, id);
48
55
  }
49
- /** Get the latest replaceable event For replaceable events (10000-19999), returns the most recent event */
56
+ /** Get the latest replaceable event For replaceable events (10000-19999 and 30000-39999), returns the most recent event */
50
57
  getReplaceable(kind, pubkey, identifier = "") {
51
58
  return getReplaceable(this.db, kind, pubkey, identifier);
52
59
  }
@@ -9,8 +9,6 @@ export declare function insertSearchContent(db: Database, event: NostrEvent, con
9
9
  export declare function deleteSearchContent(db: Database, eventId: string): void;
10
10
  /** Inserts an event into the `events`, `event_tags`, and search tables of a database */
11
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
12
  /** Removes an event by id from the `events`, `event_tags`, and search tables of a database */
15
13
  export declare function deleteEvent(db: Database, id: string): boolean;
16
14
  /** Checks if an event exists */
@@ -29,3 +27,5 @@ export declare function getEventsByFilters(db: Database, filters: FilterWithSear
29
27
  export declare function searchEvents(db: Database, search: string, options?: Filter): NostrEvent[];
30
28
  /** Rebuild the FTS5 search index for all events */
31
29
  export declare function rebuildSearchIndex(db: Database, contentFormatter: SearchContentFormatter): void;
30
+ /** Removes multiple events that match the given filters from the database */
31
+ export declare function deleteEventsByFilters(db: Database, filters: FilterWithSearch | FilterWithSearch[]): number;
@@ -1,7 +1,7 @@
1
1
  import { getIndexableTags, getReplaceableIdentifier } from "applesauce-core/helpers";
2
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";
3
+ import { buildFiltersQuery, buildDeleteFiltersQuery, rowToEvent } from "../helpers/sql.js";
4
+ import { CREATE_EVENT_TAGS_TABLE_STATEMENT, CREATE_EVENTS_TABLE_STATEMENT, CREATE_INDEXES_STATEMENTS, DELETE_EVENT_STATEMENT, GET_ALL_EVENTS_STATEMENT, GET_EVENT_STATEMENT, GET_REPLACEABLE_HISTORY_STATEMENT, GET_REPLACEABLE_STATEMENT, HAS_EVENT_STATEMENT, HAS_REPLACEABLE_STATEMENT, INSERT_EVENT_STATEMENT_WITH_IGNORE, INSERT_EVENT_TAG_STATEMENT, } from "../helpers/statements.js";
5
5
  /** Create and migrate the `events`, `event_tags`, and search tables */
6
6
  export function createTables(db, search = true) {
7
7
  // Create the events table
@@ -33,46 +33,50 @@ export function deleteSearchContent(db, eventId) {
33
33
  export function insertEvent(db, event, contentFormatter) {
34
34
  const identifier = getReplaceableIdentifier(event);
35
35
  const transaction = db.transaction(() => {
36
- // Insert/update the main event
37
- const stmt = db.query(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;
36
+ // Try to insert the main event with OR IGNORE
37
+ const result = db
38
+ .query(INSERT_EVENT_STATEMENT_WITH_IGNORE.sql)
39
+ .run(event.id, event.kind, event.pubkey, event.created_at, event.content, JSON.stringify(event.tags), event.sig, identifier);
40
+ // If no rows were changed, the event already existed
41
+ if (result.changes === 0)
42
+ return false; // Event already exists, skip tags/search processing
43
+ // Event was inserted, continue with tags and search content
44
+ const indexableTags = getIndexableTags(event);
45
+ if (indexableTags && indexableTags.size > 0) {
46
+ const insertStmt = db.query(INSERT_EVENT_TAG_STATEMENT.sql);
47
+ for (const tagString of indexableTags) {
48
+ // Parse the "tagName:tagValue" format
49
+ const [name, value] = tagString.split(":");
50
+ if (name && value)
51
+ insertStmt.run(event.id, name, value);
52
+ }
53
+ }
54
+ if (contentFormatter) {
55
+ try {
56
+ insertSearchContent(db, event, contentFormatter);
57
+ }
58
+ catch (error) {
59
+ // Search table might not exist if search is disabled, ignore the error
60
+ }
61
+ }
62
+ return true;
45
63
  });
46
64
  return transaction();
47
65
  }
48
- /** Insert indexable tags for an event into the event_tags table */
49
- export function insertEventTags(db, event) {
50
- // Clear existing tags for this event first
51
- const deleteStmt = db.query(DELETE_EVENT_TAGS_STATEMENT.sql);
52
- deleteStmt.run(event.id);
53
- // Get only the indexable tags using applesauce-core helper
54
- const indexableTags = getIndexableTags(event);
55
- if (indexableTags && indexableTags.size > 0) {
56
- const insertStmt = db.query(INSERT_EVENT_TAG_STATEMENT.sql);
57
- for (const tagString of indexableTags) {
58
- // Parse the "tagName:tagValue" format
59
- const [name, value] = tagString.split(":");
60
- if (name && value)
61
- insertStmt.run(event.id, name, value);
62
- }
63
- }
64
- }
65
66
  /** Removes an event by id from the `events`, `event_tags`, and search tables of a database */
66
67
  export function deleteEvent(db, id) {
67
68
  const transaction = db.transaction(() => {
68
- // Delete from event_tags first (foreign key constraint)
69
- const deleteTagsStmt = db.query(DELETE_EVENT_TAGS_STATEMENT.sql);
70
- deleteTagsStmt.run(id);
71
- // Delete from search tables
72
- deleteSearchContent(db, id);
73
- // Delete from events table
74
- const deleteEventStmt = db.query(DELETE_EVENT_STATEMENT.sql);
75
- const result = deleteEventStmt.run(id);
69
+ // Delete from search tables if they exist
70
+ try {
71
+ deleteSearchContent(db, id);
72
+ }
73
+ catch (error) {
74
+ // Search table might not exist if search is disabled, ignore the error
75
+ }
76
+ // Delete from events table - this will CASCADE to event_tags automatically!
77
+ // The foreign key constraint: FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
78
+ // ensures that all related event_tags records are deleted automatically
79
+ const result = db.query(DELETE_EVENT_STATEMENT.sql).run(id);
76
80
  return result.changes > 0;
77
81
  });
78
82
  return transaction();
@@ -87,25 +91,21 @@ export function hasEvent(db, id) {
87
91
  }
88
92
  /** Gets a single event from a database */
89
93
  export function getEvent(db, id) {
90
- const stmt = db.query(GET_EVENT_STATEMENT.sql);
91
- const row = stmt.get(id);
94
+ const row = db.query(GET_EVENT_STATEMENT.sql).get(id);
92
95
  return row ? rowToEvent(row) : undefined;
93
96
  }
94
97
  /** Gets the latest replaceable event from a database */
95
98
  export function getReplaceable(db, kind, pubkey, identifier) {
96
- const stmt = db.query(GET_REPLACEABLE_STATEMENT.sql);
97
- const row = stmt.get(kind, pubkey, identifier);
99
+ const row = db.query(GET_REPLACEABLE_STATEMENT.sql).get(kind, pubkey, identifier);
98
100
  return row ? rowToEvent(row) : undefined;
99
101
  }
100
102
  /** Gets the history of a replaceable event from a database */
101
103
  export function getReplaceableHistory(db, kind, pubkey, identifier) {
102
- const stmt = db.query(GET_REPLACEABLE_HISTORY_STATEMENT.sql);
103
- return stmt.all(kind, pubkey, identifier).map(rowToEvent);
104
+ return db.query(GET_REPLACEABLE_HISTORY_STATEMENT.sql).all(kind, pubkey, identifier).map(rowToEvent);
104
105
  }
105
106
  /** Checks if a replaceable event exists in a database */
106
107
  export function hasReplaceable(db, kind, pubkey, identifier = "") {
107
- const stmt = db.query(HAS_REPLACEABLE_STATEMENT.sql);
108
- const result = stmt.get(kind, pubkey, identifier);
108
+ const result = db.query(HAS_REPLACEABLE_STATEMENT.sql).get(kind, pubkey, identifier);
109
109
  if (!result)
110
110
  return false;
111
111
  return result.count > 0;
@@ -115,8 +115,7 @@ export function getEventsByFilters(db, filters) {
115
115
  const query = buildFiltersQuery(filters);
116
116
  if (!query)
117
117
  return [];
118
- const stmt = db.query(query.sql);
119
- const rows = stmt.all(...query.params);
118
+ const rows = db.query(query.sql).all(...query.params);
120
119
  // Map rows to events
121
120
  return rows.map(rowToEvent);
122
121
  }
@@ -138,11 +137,33 @@ export function rebuildSearchIndex(db, contentFormatter) {
138
137
  // Clear existing search data
139
138
  db.exec(`DELETE FROM events_search;`);
140
139
  // Rebuild from all events
141
- const stmt = db.query(GET_ALL_EVENTS_STATEMENT.sql);
142
- const events = stmt.all().map(rowToEvent);
140
+ const events = db.query(GET_ALL_EVENTS_STATEMENT.sql).all().map(rowToEvent);
143
141
  for (const event of events) {
144
142
  insertSearchContent(db, event, contentFormatter);
145
143
  }
146
144
  });
147
145
  transaction();
148
146
  }
147
+ /** Removes multiple events that match the given filters from the database */
148
+ export function deleteEventsByFilters(db, filters) {
149
+ const whereClause = buildDeleteFiltersQuery(filters);
150
+ if (!whereClause)
151
+ return 0;
152
+ const transaction = db.transaction(() => {
153
+ // Delete from search tables if they exist
154
+ try {
155
+ const searchDeleteQuery = `DELETE FROM search_content WHERE event_id IN (SELECT id FROM events ${whereClause.sql})`;
156
+ db.query(searchDeleteQuery).run(...whereClause.params);
157
+ }
158
+ catch (error) {
159
+ // Search table might not exist if search is disabled, ignore the error
160
+ }
161
+ // Delete from events table - this will CASCADE to event_tags automatically!
162
+ // The foreign key constraint: FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
163
+ // ensures that all related event_tags records are deleted automatically
164
+ const deleteEventsQuery = `DELETE FROM events ${whereClause.sql}`;
165
+ const result = db.query(deleteEventsQuery).run(...whereClause.params);
166
+ return result.changes;
167
+ });
168
+ return transaction();
169
+ }
@@ -1,10 +1,10 @@
1
- import { Filter, NostrEvent } from "applesauce-core/helpers";
1
+ import { FilterWithAnd, NostrEvent } from "applesauce-core/helpers";
2
2
  import type { Statement } from "./statements.js";
3
3
  export declare const CREATE_SEARCH_TABLE_STATEMENT: Statement<[]>;
4
4
  export declare const INSERT_SEARCH_CONTENT_STATEMENT: Statement<[string, string, number, string, number]>;
5
5
  export declare const DELETE_SEARCH_CONTENT_STATEMENT: Statement<[string]>;
6
- /** Filter with search field */
7
- export type FilterWithSearch = Filter & {
6
+ /** Filter with search field and NIP-ND AND operator support */
7
+ export type FilterWithSearch = FilterWithAnd & {
8
8
  search?: string;
9
9
  order?: "created_at" | "rank";
10
10
  };
@@ -13,3 +13,8 @@ export declare function buildFiltersQuery(filters: FilterWithSearch | FilterWith
13
13
  sql: string;
14
14
  params: any[];
15
15
  } | null;
16
+ /** Builds a WHERE clause for events that match the given filters */
17
+ export declare function buildDeleteFiltersQuery(filters: FilterWithSearch | FilterWithSearch[]): {
18
+ sql: string;
19
+ params: any[];
20
+ } | null;
@@ -49,19 +49,49 @@ export function buildFilterConditions(filter) {
49
49
  conditions.push(`events.created_at <= ?`);
50
50
  params.push(filter.until);
51
51
  }
52
- // Handle tag filters (e.g., #e, #p, #t, #d, etc.)
52
+ // Handle AND tag filters (& prefix) first - NIP-ND
53
+ // AND takes precedence and requires ALL values to be present
54
+ for (const [key, values] of Object.entries(filter)) {
55
+ if (key.startsWith("&") && values && Array.isArray(values) && values.length > 0) {
56
+ const tagName = key.slice(1); // Remove the '&' prefix
57
+ // Use a single subquery with GROUP BY + HAVING COUNT for efficiency
58
+ // This is more efficient than multiple JOINs for the same tag
59
+ const placeholders = values.map(() => "?").join(", ");
60
+ conditions.push(`events.id IN (
61
+ SELECT event_id
62
+ FROM event_tags
63
+ WHERE tag_name = ? AND tag_value IN (${placeholders})
64
+ GROUP BY event_id
65
+ HAVING COUNT(DISTINCT tag_value) = ?
66
+ )`);
67
+ // Add parameters: tagName, all tag values, then count of values
68
+ params.push(tagName, ...values, values.length);
69
+ }
70
+ }
71
+ // Handle OR tag filters (# prefix)
72
+ // Skip values that are in AND tags (NIP-ND rule)
53
73
  for (const [key, values] of Object.entries(filter)) {
54
74
  if (key.startsWith("#") && values && Array.isArray(values) && values.length > 0) {
55
75
  const tagName = key.slice(1); // Remove the '#' prefix
76
+ // Check if there's a corresponding AND filter for this tag
77
+ const andKey = `&${tagName}`;
78
+ const andValues = filter[andKey];
79
+ // Filter out values that are in AND tags (NIP-ND rule)
80
+ const filteredValues = andValues
81
+ ? values.filter((v) => !andValues.includes(v))
82
+ : values;
83
+ // Only apply OR filter if there are values left after filtering
84
+ if (filteredValues.length === 0)
85
+ continue;
56
86
  // Use the event_tags table for efficient tag filtering
57
- const placeholders = values.map(() => "?").join(", ");
87
+ const placeholders = filteredValues.map(() => "?").join(", ");
58
88
  conditions.push(`events.id IN (
59
89
  SELECT DISTINCT event_id
60
90
  FROM event_tags
61
91
  WHERE tag_name = ? AND tag_value IN (${placeholders})
62
92
  )`);
63
- // Add parameters: tagName first, then all the tag values
64
- params.push(tagName, ...values);
93
+ // Add parameters: tagName first, then all the filtered tag values
94
+ params.push(tagName, ...filteredValues);
65
95
  }
66
96
  }
67
97
  return { conditions, params, search };
@@ -120,3 +150,29 @@ export function buildFiltersQuery(filters) {
120
150
  }
121
151
  return { sql: query, params: allParams };
122
152
  }
153
+ /** Builds a WHERE clause for events that match the given filters */
154
+ export function buildDeleteFiltersQuery(filters) {
155
+ const filterArray = Array.isArray(filters) ? filters : [filters];
156
+ if (filterArray.length === 0)
157
+ return null;
158
+ const filterQueries = [];
159
+ const allParams = [];
160
+ for (const filter of filterArray) {
161
+ const { conditions, params } = buildFilterConditions(filter);
162
+ if (conditions.length === 0) {
163
+ // If no conditions, this filter matches all events
164
+ filterQueries.push("1=1");
165
+ }
166
+ else {
167
+ // AND logic within a single filter
168
+ filterQueries.push(`(${conditions.join(" AND ")})`);
169
+ }
170
+ allParams.push(...params);
171
+ }
172
+ // Combine all filter conditions with OR logic
173
+ const whereClause = filterQueries.length > 0 ? `WHERE ${filterQueries.join(" OR ")}` : "";
174
+ return {
175
+ sql: whereClause, // Just return the WHERE clause
176
+ params: allParams,
177
+ };
178
+ }
@@ -28,6 +28,17 @@ export type EventRow = {
28
28
  tags: string;
29
29
  sig: string;
30
30
  };
31
+ export declare const INSERT_EVENT_STATEMENT_WITH_IGNORE: Statement<[
32
+ string,
33
+ number,
34
+ string,
35
+ number,
36
+ string,
37
+ string,
38
+ string,
39
+ string
40
+ ]>;
41
+ /** For implementations that don't support OR IGNORE (libsql, turso-wasm) */
31
42
  export declare const INSERT_EVENT_STATEMENT: Statement<[string, number, string, number, string, string, string, string]>;
32
43
  export declare const DELETE_EVENT_TAGS_STATEMENT: Statement<[string]>;
33
44
  export declare const INSERT_EVENT_TAG_STATEMENT: Statement<[string, string, string]>;
@@ -1,13 +1,18 @@
1
1
  // Event-related statements
2
+ export const INSERT_EVENT_STATEMENT_WITH_IGNORE = {
3
+ sql: `INSERT OR IGNORE INTO events (id, kind, pubkey, created_at, content, tags, sig, identifier)
4
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
5
+ };
6
+ /** For implementations that don't support OR IGNORE (libsql, turso-wasm) */
2
7
  export const INSERT_EVENT_STATEMENT = {
3
- sql: `INSERT OR REPLACE INTO events (id, kind, pubkey, created_at, content, tags, sig, identifier)
8
+ sql: `INSERT INTO events (id, kind, pubkey, created_at, content, tags, sig, identifier)
4
9
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
5
10
  };
6
11
  export const DELETE_EVENT_TAGS_STATEMENT = {
7
12
  sql: `DELETE FROM event_tags WHERE event_id = ?`,
8
13
  };
9
14
  export const INSERT_EVENT_TAG_STATEMENT = {
10
- sql: `INSERT OR IGNORE INTO event_tags (event_id, tag_name, tag_value) VALUES (?, ?, ?)`,
15
+ sql: `INSERT INTO event_tags (event_id, tag_name, tag_value) VALUES (?, ?, ?)`,
11
16
  };
12
17
  export const DELETE_EVENT_STATEMENT = {
13
18
  sql: `DELETE FROM events WHERE id = ?`,