nostrb 0.1.0.1

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.
@@ -0,0 +1,441 @@
1
+ require 'sqlite3'
2
+ require 'nostrb/filter'
3
+
4
+ module Nostrb
5
+ module SQLite
6
+ class Pragma
7
+ ENUM = {
8
+ 'auto_vacuum' => %w[none full incremental],
9
+ 'synchronous' => %w[off normal full],
10
+ 'temp_store' => %w[default file memory],
11
+ }
12
+
13
+ RO = %w[data_version freelist_count page_count]
14
+ RW = %w[application_id analysis_limit auto_vacuum automatic_index
15
+ busy_timeout cache_size cache_spill cell_size_check
16
+ checkpoint_fullfsync defer_foreign_keys encoding foreign_keys
17
+ fullfsync hard_heap_limit ignore_check_constraints journal_mode
18
+ journal_size_limit locking_mode max_page_count mmap_size
19
+ page_size query_only read_uncommitted recursive_triggers
20
+ reverse_unordered_selects secure_delete soft_heap_limit
21
+ synchronous temp_store threads trusted_schema user_version
22
+ wal_autocheckpoint]
23
+ SCALAR = (RO + RW).sort
24
+
25
+ # debug: parser_trace schema_version stats vdbe_* writable_schema
26
+ # legacy: legacy_alter_table legacy_file_format
27
+ # deprecated: case_sensitive_like count_changes data_store_directory
28
+ # default_cache_size empty_result_callbacks full_column_names
29
+ # short_column_names temp_store_directory
30
+
31
+ # either no args (nil) or a single arg (symbol)
32
+ REPORT = {
33
+ compile_options: nil,
34
+ # list
35
+ collation_list: nil,
36
+ database_list: nil,
37
+ function_list: nil,
38
+ module_list: nil,
39
+ pragma_list: nil,
40
+ table_list: nil,
41
+ index_list: :table_name,
42
+ foreign_key_list: :table_name,
43
+ # info
44
+ index_info: :index_name,
45
+ index_xinfo: :index_name,
46
+ table_info: :table_name,
47
+ table_xinfo: :table_name,
48
+ }
49
+
50
+ # either no args (nil) or an optional single arg (symbol)
51
+ COMMAND = {
52
+ # checks
53
+ foreign_key_check: :optional, # table_name => report
54
+ integrity_check: :optional, # table_name | num_errors => ok
55
+ quick_check: :optional, # table_name | num_errors => ok
56
+ # manipulation
57
+ incremental_vacuum: :optional, # page_count => empty
58
+ optimize: :optional, # mask => empty
59
+ shrink_memory: nil, # empty
60
+ wal_checkpoint: :optional, # PASSIVE | FULL | RESTART | TRUNCATE => row
61
+ }
62
+
63
+ def initialize(db)
64
+ @db = db
65
+ end
66
+
67
+ def get(pragma)
68
+ @db.execute("PRAGMA #{pragma}")[0][0]
69
+ end
70
+
71
+ def set(pragma, val)
72
+ @db.execute("PRAGMA #{pragma} = #{val}")
73
+ get(pragma)
74
+ end
75
+
76
+ # just the rows
77
+ def list(pragma, arg = nil)
78
+ if arg.nil?
79
+ @db.execute("PRAGMA #{pragma}")
80
+ else
81
+ @db.execute("PRAGMA #{pragma}(#{arg})")
82
+ end
83
+ end
84
+
85
+ # include a header row
86
+ def report(pragma, arg = nil)
87
+ if arg.nil?
88
+ @db.execute2("PRAGMA #{pragma}")
89
+ else
90
+ @db.execute2("PRAGMA #{pragma}(#{arg})")
91
+ end
92
+ end
93
+
94
+ SCALAR.each { |pragma| define_method(pragma) { get(pragma) }}
95
+ RW.each { |pragma|
96
+ define_method(pragma + '=') { |val| set(pragma, val) }
97
+ }
98
+
99
+ (REPORT.merge(COMMAND)).each { |pragma, arg|
100
+ if arg.nil?
101
+ define_method(pragma) { report(pragma) }
102
+ elsif arg == :optional
103
+ define_method(pragma) { |val=nil| report(pragma, val) }
104
+ else
105
+ define_method(pragma) { |val| report(pragma, val) }
106
+ end
107
+ }
108
+ end
109
+
110
+ class Storage
111
+ KB = 1024
112
+ MB = KB * 1024
113
+ GB = MB * 1024
114
+
115
+ FILENAME = 'tmp.db'
116
+ CONFIG = {
117
+ default_transaction_mode: :immediate,
118
+ }
119
+ SQLITE_USAGE = /\Asqlite_/
120
+
121
+ PRAGMAS = {
122
+ foreign_keys: true, # enable FK constraints
123
+ mmap_size: 128 * MB, # enable mmap I/O, 128 MB
124
+
125
+ # Write Ahead Log, append-only so safe for infrequent fsync
126
+ journal_mode: 'wal', # enable WAL, less read/write contention
127
+ journal_size_limit: 64 * MB, # enable, 64 MB
128
+ synchronous: 1, # 1=normal, default, good for WAL
129
+ wal_autocheckpoint: 1000, # default, pages per fsync
130
+ }
131
+
132
+ attr_reader :filename, :db, :pragma
133
+
134
+ def initialize(filename = FILENAME, set_pragmas: true, **kwargs)
135
+ @filename = filename
136
+ @db = SQLite3::Database.new(@filename, **CONFIG.merge(kwargs))
137
+ @db.busy_handler_timeout = 5000 # 5 seconds, release GVL every ms
138
+ @pragma = Pragma.new(@db)
139
+ self.set_pragmas if set_pragmas
140
+ end
141
+
142
+ def set_pragmas
143
+ PRAGMAS.each { |name, val| @pragma.set(name, val) }
144
+ end
145
+
146
+ def setup
147
+ Setup.new(@filename)
148
+ end
149
+
150
+ def reader
151
+ Reader.new(@filename)
152
+ end
153
+
154
+ def writer
155
+ Writer.new(@filename)
156
+ end
157
+
158
+ # below methods all return an array of strings
159
+ def compile_options = @pragma.list(:compile_options).map { |a| a[0] }
160
+ def database_files = @pragma.list(:database_list).map { |a| a[2] }
161
+ def all_table_names = @pragma.list(:table_list).map { |a| a[1] }
162
+
163
+ def table_names
164
+ all_table_names().select { |name| !SQLITE_USAGE.match name }
165
+ end
166
+
167
+ def all_index_names(table_name)
168
+ @pragma.list(:index_list, table_name).map { |a| a[1] }
169
+ end
170
+
171
+ def index_names(table_name)
172
+ all_index_names(table_name).select { |name| !SQLITE_USAGE.match name }
173
+ end
174
+
175
+ def pragma_scalars
176
+ Pragma::SCALAR.map { |pragma|
177
+ val, enum = @pragma.get(pragma), Pragma::ENUM[pragma]
178
+ val = format("%i (%s)", val, enum[val]) if enum
179
+ format("%s: %s", pragma, val)
180
+ }
181
+ end
182
+
183
+ def report
184
+ lines = ['compile_options', '---']
185
+ lines += self.compile_options
186
+
187
+ lines += ['', 'database_files', '---']
188
+ lines += self.database_files
189
+
190
+ lines += ['', "table_names", '---']
191
+ tables = self.table_names
192
+ lines += tables
193
+
194
+ tables.each { |tbl|
195
+ lines += ['', "table_info(#{tbl})", '---']
196
+ lines += @pragma.table_info(tbl).map(&:inspect)
197
+
198
+ fks = @pragma.foreign_key_list(tbl).map(&:inspect)
199
+ if fks.length > 1
200
+ lines += ['', "foreign_key_list(#{tbl})", '---']
201
+ lines += fks
202
+ end
203
+
204
+ idxs = self.index_names(tbl)
205
+ if !idxs.empty?
206
+ lines += ['', "index_names(#{tbl})", '---']
207
+ lines += idxs
208
+ end
209
+
210
+ idxs.each { |idx|
211
+ lines += ['', "index_info(#{idx})", '---']
212
+ lines += @pragma.index_info(idx).map(&:inspect)
213
+ }
214
+ }
215
+
216
+ lines += ['', "pragma values", '---']
217
+ lines += self.pragma_scalars
218
+ lines
219
+ end
220
+ end
221
+
222
+ class Setup < Storage
223
+ def setup
224
+ drop_tables
225
+ create_tables
226
+ create_indices
227
+ report
228
+ end
229
+
230
+ def drop_tables
231
+ %w[events tags r_events r_tags].each { |tbl|
232
+ @db.execute "DROP TABLE IF EXISTS #{tbl}"
233
+ }
234
+ end
235
+
236
+ def create_tables
237
+ @db.execute <<SQL
238
+ CREATE TABLE events (content TEXT NOT NULL,
239
+ kind INT NOT NULL,
240
+ tags TEXT NOT NULL,
241
+ pubkey TEXT NOT NULL,
242
+ created_at INT NOT NULL,
243
+ id TEXT PRIMARY KEY NOT NULL,
244
+ sig TEXT NOT NULL)
245
+ SQL
246
+
247
+ @db.execute <<SQL
248
+ CREATE TABLE tags (event_id TEXT NOT NULL REFERENCES events (id)
249
+ ON DELETE CASCADE ON UPDATE CASCADE,
250
+ created_at INT NOT NULL,
251
+ tag TEXT NOT NULL,
252
+ value TEXT NOT NULL,
253
+ json TEXT NOT NULL)
254
+ SQL
255
+
256
+ @db.execute <<SQL
257
+ CREATE TABLE r_events (content TEXT NOT NULL,
258
+ kind INT NOT NULL,
259
+ tags TEXT NOT NULL,
260
+ d_tag TEXT DEFAULT NULL,
261
+ pubkey TEXT NOT NULL,
262
+ created_at INT NOT NULL,
263
+ id TEXT PRIMARY KEY NOT NULL,
264
+ sig TEXT NOT NULL)
265
+ SQL
266
+
267
+ @db.execute <<SQL
268
+ CREATE TABLE r_tags (r_event_id TEXT NOT NULL REFERENCES r_events (id)
269
+ ON DELETE CASCADE ON UPDATE CASCADE,
270
+ created_at INT NOT NULL,
271
+ tag TEXT NOT NULL,
272
+ value TEXT NOT NULL,
273
+ json TEXT NOT NULL)
274
+ SQL
275
+ end
276
+
277
+ def create_indices
278
+ @db.execute "CREATE INDEX idx_events_created_at
279
+ ON events (created_at)"
280
+ @db.execute "CREATE INDEX idx_tags_created_at
281
+ ON tags (created_at)"
282
+ @db.execute "CREATE INDEX idx_r_events_created_at
283
+ ON r_events (created_at)"
284
+ @db.execute "CREATE INDEX idx_r_tags_created_at
285
+ ON r_tags (created_at)"
286
+
287
+ @db.execute "CREATE UNIQUE INDEX unq_r_events_kind_pubkey_d_tag
288
+ ON r_events (kind, pubkey, d_tag)"
289
+ end
290
+ end
291
+
292
+ class Reader < Storage
293
+ PRAGMAS = Storage::PRAGMAS.merge(query_only: true)
294
+
295
+ # parse the JSON tags into a Ruby array
296
+ def self.hydrate(hash)
297
+ hash["tags"] = Nostrb.parse(hash.fetch("tags"))
298
+ hash
299
+ end
300
+
301
+ def self.event_clauses(filter)
302
+ clauses = []
303
+ if !filter.ids.empty?
304
+ clauses << format("id IN ('%s')", filter.ids.join("','"))
305
+ end
306
+ if !filter.authors.empty?
307
+ clauses << format("pubkey in ('%s')", filter.authors.join("','"))
308
+ end
309
+ if !filter.kinds.empty?
310
+ clauses << format("kind in (%s)", filter.kinds.join(','))
311
+ end
312
+ if filter.since
313
+ clauses << format("created_at >= %i", filter.since)
314
+ end
315
+ if filter.until
316
+ clauses << format("created_at <= %i", filter.until)
317
+ end
318
+ clauses.join(' AND ')
319
+ end
320
+
321
+ # filter_tags: { 'a' => [String] }
322
+ def self.tag_clauses(filter_tags)
323
+ clauses = []
324
+ filter_tags.each { |tag, values|
325
+ clauses << format("tag = %s", tag)
326
+ clauses << format("value in (%s)", values.join(','))
327
+ }
328
+ clauses.join(' AND ')
329
+ end
330
+
331
+ def initialize(filename = FILENAME, **kwargs)
332
+ super(filename, **kwargs.merge(readonly: true))
333
+ end
334
+
335
+ #
336
+ # Regular Events
337
+ #
338
+
339
+ def select_events_table(table = 'events', filter = nil)
340
+ sql = format("SELECT content, kind, tags, pubkey, created_at, id, sig
341
+ FROM %s", table)
342
+ if !filter.nil?
343
+ sql += format(" WHERE %s", Reader.event_clauses(filter))
344
+ end
345
+ @db.query sql
346
+ end
347
+
348
+ def process_events_table(table = 'events', filter = nil)
349
+ a = []
350
+ select_events_table(table, filter).each_hash { |h|
351
+ a << Reader.hydrate(h)
352
+ }
353
+ a
354
+ end
355
+
356
+ # these are presumably filtered so cannot be prepared
357
+ # use Database#query to get a ResultSet
358
+ def select_events(filter = nil)
359
+ select_events_table('events', filter)
360
+ end
361
+
362
+ def process_events(filter = nil)
363
+ process_events_table('events', filter)
364
+ end
365
+
366
+ # use a prepared statement to get a ResultSet
367
+ def select_tags(event_id:, created_at:)
368
+ @select_tags ||= @db.prepare("SELECT tag, value, json
369
+ FROM tags
370
+ WHERE event_id = :event_id
371
+ AND created_at = :created_at")
372
+ @select_tags.execute(event_id: event_id, created_at: created_at)
373
+ end
374
+
375
+ #
376
+ # Replaceable Events
377
+ #
378
+
379
+ def select_r_events(filter = nil)
380
+ select_events_table('r_events', filter)
381
+ end
382
+
383
+ def process_r_events(filter = nil)
384
+ process_events_table('r_events', filter)
385
+ end
386
+
387
+ def select_r_tags(event_id:, created_at:)
388
+ @select_r_tags ||= @db.prepare("SELECT tag, value, json
389
+ FROM r_tags
390
+ WHERE r_event_id = :event_id
391
+ AND created_at = :created_at")
392
+ @select_r_tags.execute(event_id: event_id, created_at: created_at)
393
+ end
394
+ end
395
+
396
+ class Writer < Storage
397
+ # a valid hash, as returned from SignedEvent.validate!
398
+ def add_event(valid)
399
+ @add_event ||= @db.prepare("INSERT INTO events
400
+ VALUES (:content, :kind, :tags, :pubkey,
401
+ :created_at, :id, :sig)")
402
+ @add_tag ||= @db.prepare("INSERT INTO tags
403
+ VALUES (:event_id, :created_at,
404
+ :tag, :value, :json)")
405
+ tags = valid["tags"]
406
+ valid["tags"] = Nostrb.json(tags)
407
+ @add_event.execute(valid) # insert event
408
+ tags.each { |a| # insert tags
409
+ @add_tag.execute(event_id: valid.fetch('id'),
410
+ created_at: valid.fetch('created_at'),
411
+ tag: a[0],
412
+ value: a[1],
413
+ json: Nostrb.json(a))
414
+ }
415
+ end
416
+
417
+ # add replaceable event
418
+ def add_r_event(valid)
419
+ @add_r_event ||=
420
+ @db.prepare("INSERT OR REPLACE INTO r_events
421
+ VALUES (:content, :kind, :tags, :d_tag,
422
+ :pubkey, :created_at, :id, :sig)")
423
+ @add_rtag ||= @db.prepare("INSERT INTO r_tags
424
+ VALUES (:r_event_id, :created_at,
425
+ :tag, :value, :json)")
426
+ tags = valid.fetch('tags')
427
+ d_tags = tags.select { |a| a[0] == 'd' }
428
+ valid['d_tag'] = d_tags.empty? ? nil : d_tags[0][1]
429
+ valid['tags'] = Nostrb.json(tags)
430
+ @add_r_event.execute(valid) # upsert event
431
+ tags.each { |a| # insert tags
432
+ @add_rtag.execute(r_event_id: valid.fetch('id'),
433
+ created_at: valid.fetch('created_at'),
434
+ tag: a[0],
435
+ value: a[1],
436
+ json: Nostrb.json(a))
437
+ }
438
+ end
439
+ end
440
+ end
441
+ end
data/lib/nostrb.rb ADDED
@@ -0,0 +1,111 @@
1
+ require 'digest' # stdlib
2
+ require 'schnorr_sig' # gem
3
+ begin
4
+ require 'nostrb/oj' # try Oj gem
5
+ rescue LoadError
6
+ require 'nostrb/json' # fall back to stdlib
7
+ end
8
+
9
+ module Nostrb
10
+ GEMS = %w[rbsecp256k1 oj sqlite3 sequel]
11
+
12
+ class Error < RuntimeError; end
13
+ class SizeError < Error; end
14
+ class FormatError < Error; end
15
+
16
+ # return 32 bytes binary
17
+ def self.digest(str) = Digest::SHA256.digest(str)
18
+
19
+ def self.check!(val, cls)
20
+ val.is_a?(cls) ? val : raise(TypeError, "#{cls} expected: #{val.inspect}")
21
+ end
22
+
23
+ def self.int!(int, max: nil)
24
+ check!(int, Integer)
25
+ raise(SizeError, "#{int} > #{max} (max)") if !max.nil? and int > max
26
+ int
27
+ end
28
+
29
+ def self.kind!(kind)
30
+ int!(kind, max: 65535)
31
+ end
32
+
33
+ def self.ary!(ary, max: nil)
34
+ check!(ary, Array)
35
+ if !max.nil? and ary.length > max
36
+ raise(SizeError, "#{ary.length} > #{max} (max)")
37
+ end
38
+ ary
39
+ end
40
+
41
+ def self.str!(str, binary: nil, length: nil, max: nil)
42
+ check!(str, String)
43
+ if !binary.nil? and !binary == (str.encoding == Encoding::BINARY)
44
+ raise(EncodingError, str.encoding)
45
+ end
46
+ raise(SizeError, str.length) if !length.nil? and str.length != length
47
+ raise(SizeError, str.length) if !max.nil? and str.length > max
48
+ str
49
+ end
50
+
51
+ def self.bin!(str, length: nil, max: nil)
52
+ str!(str, binary: true, length: length, max: max)
53
+ end
54
+ def self.key!(str) = bin!(str, length: 32)
55
+
56
+
57
+ def self.txt!(str, length: nil, max: nil)
58
+ str!(str, binary: false, length: length, max: max)
59
+ end
60
+ def self.pubkey!(str) = txt!(str, length: 64)
61
+ def self.id!(str) = txt!(str, length: 64)
62
+ def self.sid!(str) = txt!(str, max: 64)
63
+ def self.sig!(str) = txt!(str, length: 128)
64
+
65
+ HELP_MSG = /\A[a-zA-Z0-9\-_]+: [[:print:]]*\z/
66
+
67
+ def self.help!(str)
68
+ raise(FormatError, str) unless txt!(str, max: 1024).match HELP_MSG
69
+ str
70
+ end
71
+
72
+ def self.tags!(ary)
73
+ ary!(ary, max: 9999).each { |a| ary!(a, max: 99).each { |s| txt!(s) } }
74
+ end
75
+
76
+ def self.rbsecp256k1?
77
+ begin
78
+ require 'rbsecp256k1'; Secp256k1
79
+ rescue LoadError, NameError
80
+ false
81
+ end
82
+ end
83
+
84
+ def self.oj?
85
+ begin
86
+ require 'oj'; Oj
87
+ rescue LoadError, NameError
88
+ false
89
+ end
90
+ end
91
+
92
+ def self.sqlite3?
93
+ begin
94
+ require 'sqlite3'; SQLite3
95
+ rescue LoadError, NameError
96
+ false
97
+ end
98
+ end
99
+
100
+ def self.sequel?
101
+ begin
102
+ require 'sequel'; Sequel
103
+ rescue LoadError, NameError
104
+ false
105
+ end
106
+ end
107
+
108
+ def self.gem_check
109
+ GEMS.map { |gem| [gem, self.send("#{gem}?")] }.to_h
110
+ end
111
+ end
data/nostrb.gemspec ADDED
@@ -0,0 +1,18 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'nostrb'
3
+ s.summary = "Minimal Nostr library in Ruby"
4
+ s.description = "TBD"
5
+ s.authors = ["Rick Hull"]
6
+ s.homepage = "https://github.com/rickhull/nostrb"
7
+ s.license = "LGPL-2.1-only"
8
+
9
+ s.required_ruby_version = "~> 3.0"
10
+
11
+ s.version = File.read(File.join(__dir__, 'VERSION')).chomp
12
+
13
+ s.files = %w[nostrb.gemspec VERSION Rakefile]
14
+ s.files += Dir['lib/**/*.rb']
15
+ s.files += Dir['test/**/*.rb']
16
+
17
+ s.add_dependency "schnorr_sig", "~> 1.0"
18
+ end
data/test/common.rb ADDED
@@ -0,0 +1,25 @@
1
+ require 'nostrb/event'
2
+
3
+ module Nostrb
4
+ module Test
5
+ SK, PK = SchnorrSig.keypair
6
+
7
+ def self.new_event(content = 'testing')
8
+ Event.new(content, pk: PK).sign(SK)
9
+ end
10
+
11
+ EVENT = Event.new('testing', pk: PK)
12
+ SIGNED = EVENT.sign(SK)
13
+ HASH = SIGNED.to_h
14
+
15
+ STATIC_HASH = {
16
+ "content" => "hello world",
17
+ "pubkey" => "18a2f562682d3ccaee89297eeee89a7961bc417bad98e9a3a93f010b0ea5313d",
18
+ "kind" => 1,
19
+ "tags" => [],
20
+ "created_at" => 1725496781,
21
+ "id" => "7f6f1c7ee406a450b581c62754fa66ffaaff0504b40ced02a6d0fc3806f1d44b",
22
+ "sig" => "8bb25f403e90cbe83629098264327b56240a703820b26f440a348ae81a64ec490c18e61d2942fe300f26b93a1534a94406aec12f5a32272357263bea88fccfda"
23
+ }
24
+ end
25
+ end