nostrb 0.1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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