bsv-wallet-postgres 0.6.0 → 0.100.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -123
  3. data/LICENSE +23 -80
  4. data/db/migrations/001_create_schema.rb +261 -0
  5. data/db/migrations/002_action_id_cascade.rb +66 -0
  6. data/db/migrations/003_schema_constraints.rb +297 -0
  7. data/db/migrations/004_drop_tx_reqs.rb +32 -0
  8. data/lib/bsv/wallet/postgres/action.rb +80 -0
  9. data/lib/bsv/wallet/postgres/action_label.rb +14 -0
  10. data/lib/bsv/wallet/postgres/arc_adapter.rb +32 -0
  11. data/lib/bsv/wallet/postgres/basket.rb +13 -0
  12. data/lib/bsv/wallet/postgres/block.rb +13 -0
  13. data/lib/bsv/wallet/postgres/broadcast.rb +87 -0
  14. data/lib/bsv/wallet/postgres/broadcast_callback.rb +54 -0
  15. data/lib/bsv/wallet/postgres/broadcast_queue.rb +98 -0
  16. data/lib/bsv/wallet/postgres/certificate.rb +13 -0
  17. data/lib/bsv/wallet/postgres/certificate_field.rb +13 -0
  18. data/lib/bsv/wallet/postgres/display_txid.rb +25 -0
  19. data/lib/bsv/wallet/postgres/input.rb +14 -0
  20. data/lib/bsv/wallet/postgres/label.rb +15 -0
  21. data/lib/bsv/wallet/postgres/output.rb +64 -0
  22. data/lib/bsv/wallet/postgres/output_basket.rb +15 -0
  23. data/lib/bsv/wallet/postgres/output_detail.rb +12 -0
  24. data/lib/bsv/wallet/postgres/output_tag.rb +14 -0
  25. data/lib/bsv/wallet/postgres/proof_store.rb +109 -0
  26. data/lib/bsv/wallet/postgres/setting.rb +32 -0
  27. data/lib/bsv/wallet/postgres/spendable.rb +12 -0
  28. data/lib/bsv/wallet/postgres/store.rb +580 -0
  29. data/lib/bsv/wallet/postgres/tag.rb +15 -0
  30. data/lib/bsv/wallet/postgres/tx_proof.rb +16 -0
  31. data/lib/bsv/wallet/postgres/utxo_pool.rb +58 -0
  32. data/lib/bsv/wallet/postgres/version.rb +9 -0
  33. data/lib/bsv/wallet/postgres.rb +77 -0
  34. data/lib/bsv-wallet-postgres.rb +1 -1
  35. metadata +49 -35
  36. data/lib/bsv/wallet_postgres/migrations/001_create_wallet_tables.rb +0 -58
  37. data/lib/bsv/wallet_postgres/migrations/002_add_output_state.rb +0 -33
  38. data/lib/bsv/wallet_postgres/migrations/003_add_wallet_settings.rb +0 -20
  39. data/lib/bsv/wallet_postgres/migrations/004_add_pending_metadata.rb +0 -69
  40. data/lib/bsv/wallet_postgres/migrations/005_add_txid_unique_index.rb +0 -27
  41. data/lib/bsv/wallet_postgres/migrations/006_create_broadcast_jobs.rb +0 -68
  42. data/lib/bsv/wallet_postgres/postgres_store.rb +0 -502
  43. data/lib/bsv/wallet_postgres/solid_queue_adapter.rb +0 -328
  44. data/lib/bsv/wallet_postgres/version.rb +0 -7
  45. data/lib/bsv/wallet_postgres.rb +0 -13
@@ -1,502 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'sequel'
4
- require 'sequel/extensions/migration'
5
- require 'json'
6
-
7
- module BSV
8
- module Wallet
9
- # PostgreSQL-backed storage adapter for +BSV::Wallet+.
10
- #
11
- # Implements the full {Store} interface against a Sequel
12
- # +Database+ object. Survives process restarts, scales to multiple
13
- # instances, and is thread-safe via Sequel's connection pool.
14
- #
15
- # @example Quickstart
16
- # require 'bsv-wallet-postgres'
17
- #
18
- # db = Sequel.connect(ENV['DATABASE_URL'])
19
- # BSV::Wallet::PostgresStore.migrate!(db)
20
- #
21
- # store = BSV::Wallet::PostgresStore.new(db)
22
- # wallet = BSV::Wallet::Client.new(key, storage: store)
23
- #
24
- # @example Bringing your own migration runner
25
- # # Copy lib/bsv/wallet_postgres/migrations/001_create_wallet_tables.rb
26
- # # into your own db/migrate directory, then run your framework's
27
- # # migrator as normal. `migrate!` is a convenience — not a requirement.
28
- #
29
- # === Design notes
30
- #
31
- # * JSONB is the source of truth. Every row stores the full record
32
- # hash in a +data+ jsonb column; dedicated indexed columns
33
- # (+basket+, +tags+, +labels+, +certifier+, ...) exist only to make
34
- # queries fast. Reads return the jsonb blob so adding fields to
35
- # bsv-wallet's record hashes does not require a schema change.
36
- #
37
- # * Outputs upsert on +outpoint+ (unique); certificates upsert on the
38
- # composite unique +(type, serial_number, certifier)+. Proofs and
39
- # transactions upsert on their +txid+ primary key. Actions are
40
- # append-only — the interface has no natural key for actions.
41
- #
42
- # * Pagination is ordered by insertion (+id ASC+) to match MemoryStore.
43
- #
44
- # * This class is thread-safe because Sequel is — the adapter itself
45
- # holds no mutable state beyond the injected database handle.
46
- class PostgresStore
47
- include Interface::Store
48
-
49
- MIGRATIONS_DIR = File.expand_path('migrations', __dir__)
50
-
51
- # Run the shipped wallet schema migrations against +db+.
52
- #
53
- # Uses Sequel's migrator so every schema change ships as a numbered
54
- # migration file and the database tracks which ones have been
55
- # applied. Safe to call repeatedly.
56
- #
57
- # Consumers who prefer their own migration framework can copy the
58
- # migration file(s) out of +lib/bsv/wallet_postgres/migrations/+
59
- # instead of calling this helper.
60
- #
61
- # @param db [Sequel::Database]
62
- # @return [void]
63
- def self.migrate!(db)
64
- Sequel::Migrator.run(db, MIGRATIONS_DIR)
65
- end
66
-
67
- # Register the global Sequel query-builder helpers used by this class
68
- # (+Sequel.pg_array_op+ / +Sequel.pg_jsonb_op+). Unlike the per-database
69
- # +pg_array+ / +pg_json+ extensions loaded in +initialize+, +pg_array_ops+
70
- # and +pg_json_ops+ are global — they mutate Sequel's top-level namespace
71
- # the first time this class body is evaluated (typically on autoload).
72
- # This is an intentional side effect: any consumer that has
73
- # +require 'bsv-wallet-postgres'+ in their Gemfile has opted in.
74
- Sequel.extension :pg_array_ops
75
- Sequel.extension :pg_json_ops
76
-
77
- # @param db [Sequel::Database] a Sequel database handle. The caller
78
- # owns connection lifecycle, pool sizing, and migrations.
79
- def initialize(db)
80
- @db = db
81
- @db.extension :pg_array
82
- @db.extension :pg_json
83
- end
84
-
85
- # @return [Sequel::Database] the underlying database handle
86
- attr_reader :db
87
-
88
- # --- Actions ---
89
-
90
- def store_action(action_data)
91
- row = action_row(action_data)
92
- @db[:wallet_actions].insert(row)
93
- action_data
94
- end
95
-
96
- def find_actions(query)
97
- ds = filter_actions(@db[:wallet_actions], query)
98
- paginate(ds, query).map { |r| symbolise_keys(r[:data]) }
99
- end
100
-
101
- def count_actions(query)
102
- filter_actions(@db[:wallet_actions], query).count
103
- end
104
-
105
- def update_action_status(txid, new_status)
106
- # Fetch by txid first, then update by primary key so only exactly one
107
- # row is targeted. The unique index on txid makes this unambiguous, but
108
- # scoping to the id column makes the intent explicit and is safe even
109
- # on databases where the migration has not yet been applied.
110
- row = @db[:wallet_actions].where(txid: txid).first
111
- raise WalletError, "Action not found: #{txid}" unless row
112
-
113
- @db[:wallet_actions].where(id: row[:id]).update(
114
- data: Sequel.lit(
115
- "data || jsonb_build_object('status', ?)",
116
- new_status
117
- )
118
- )
119
- symbolise_keys(@db[:wallet_actions].where(id: row[:id]).first[:data])
120
- end
121
-
122
- def delete_action(txid)
123
- @db[:wallet_actions].where(txid: txid).delete.positive?
124
- end
125
-
126
- # --- Outputs ---
127
-
128
- def store_output(output_data)
129
- row = output_row(output_data)
130
- @db[:wallet_outputs]
131
- .insert_conflict(
132
- target: :outpoint,
133
- update: {
134
- basket: row[:basket],
135
- tags: row[:tags],
136
- spendable: row[:spendable],
137
- state: row[:state],
138
- satoshis: row[:satoshis],
139
- data: row[:data]
140
- }
141
- )
142
- .insert(row)
143
- output_data
144
- end
145
-
146
- def find_outputs(query)
147
- ds = filter_outputs(@db[:wallet_outputs], query)
148
- paginate(ds, query).map { |r| symbolise_keys(r[:data]) }
149
- end
150
-
151
- def count_outputs(query)
152
- filter_outputs(@db[:wallet_outputs], query).count
153
- end
154
-
155
- def delete_output(outpoint)
156
- @db[:wallet_outputs].where(outpoint: outpoint).delete.positive?
157
- end
158
-
159
- # Returns outputs whose effective state is +:spendable+.
160
- #
161
- # Legacy rows with +state = NULL+ are treated as spendable when the
162
- # +spendable+ boolean is true (or absent), matching MemoryStore's
163
- # effective_state logic.
164
- #
165
- # @param basket [String, nil] restrict to this basket when provided
166
- # @param min_satoshis [Integer, nil] exclude outputs below this value
167
- # @param sort_order [Symbol] +:asc+ or +:desc+ (default +:desc+, largest first)
168
- # @return [Array<Hash>]
169
- def find_spendable_outputs(basket: nil, min_satoshis: nil, sort_order: :desc)
170
- ds = @db[:wallet_outputs]
171
- .where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable'))
172
- ds = ds.where(basket: basket) if basket
173
- if min_satoshis
174
- ds = ds.where(
175
- Sequel.lit('COALESCE(satoshis, (data->>?)::bigint, 0) >= ?', 'satoshis', min_satoshis)
176
- )
177
- end
178
- satoshis_expr = Sequel.lit('COALESCE(satoshis, (data->>?)::bigint, 0)', 'satoshis')
179
- ds = ds.order(sort_order == :asc ? Sequel.asc(satoshis_expr) : Sequel.desc(satoshis_expr))
180
- ds.all.map { |r| symbolise_keys(r[:data]) }
181
- end
182
-
183
- # Transitions the state of an existing output.
184
- #
185
- # When +new_state+ is +:pending+, sets +pending_since+, +pending_reference+,
186
- # and +no_send+, and merges those values into the JSONB +data+ blob.
187
- #
188
- # When transitioning away from +:pending+, clears the pending metadata
189
- # columns and removes the corresponding keys from the JSONB blob.
190
- #
191
- # @param outpoint [String] the outpoint identifier
192
- # @param new_state [Symbol] +:spendable+, +:pending+, or +:spent+
193
- # @param pending_reference [String, nil] caller-supplied label for a pending lock
194
- # @param no_send [Boolean, nil] true if the lock belongs to a no_send transaction
195
- # @raise [BSV::Wallet::WalletError] if the outpoint is not found
196
- # @return [Hash] the updated output hash
197
- def update_output_state(outpoint, new_state, pending_reference: nil, no_send: nil)
198
- state_str = new_state.to_s
199
-
200
- # Keep legacy spendable boolean in sync so filter_outputs and other
201
- # queries that haven't migrated to the state column still work.
202
- spendable_bool = new_state == :spendable
203
-
204
- if new_state == :pending
205
- updates = {
206
- state: state_str,
207
- spendable: spendable_bool,
208
- pending_since: Sequel.lit('NOW()'),
209
- pending_reference: pending_reference,
210
- no_send: no_send ? true : false,
211
- data: Sequel.lit(
212
- "data || jsonb_build_object('state', ?, 'pending_since', NOW()::text, 'pending_reference', ?, 'no_send', ?)",
213
- state_str, pending_reference, no_send ? true : false
214
- )
215
- }
216
- else
217
- updates = {
218
- state: state_str,
219
- spendable: spendable_bool,
220
- pending_since: nil,
221
- pending_reference: nil,
222
- no_send: false
223
- }
224
- # Remove pending keys from JSONB blob, update state
225
- updates[:data] = Sequel.lit(
226
- "(data - 'pending_since' - 'pending_reference' - 'no_send') || jsonb_build_object('state', ?)",
227
- state_str
228
- )
229
- end
230
-
231
- ds = @db[:wallet_outputs].where(outpoint: outpoint)
232
- rows_updated = ds.update(updates)
233
- raise WalletError, "Output not found: #{outpoint}" if rows_updated.zero?
234
-
235
- row = ds.first
236
- symbolise_keys(row[:data])
237
- end
238
-
239
- # Moves an output from one basket to another (metadata-only).
240
- #
241
- # Updates both the +basket+ column and the JSONB +data+ blob.
242
- # Does not affect the output's state or pending metadata.
243
- #
244
- # @param outpoint [String] the outpoint identifier
245
- # @param new_basket [String] the destination basket name
246
- # @raise [BSV::Wallet::WalletError] if the outpoint is not found
247
- # @return [Hash] the updated output hash
248
- def update_output_basket(outpoint, new_basket)
249
- ds = @db[:wallet_outputs].where(outpoint: outpoint)
250
- rows_updated = ds.update(
251
- basket: new_basket,
252
- data: Sequel.lit("data || jsonb_build_object('basket', ?)", new_basket)
253
- )
254
- raise WalletError, "Output not found: #{outpoint}" if rows_updated.zero?
255
-
256
- symbolise_keys(ds.first[:data])
257
- end
258
-
259
- # Atomically marks a set of outpoints as +:pending+.
260
- #
261
- # Uses +UPDATE ... WHERE state = 'spendable' ... RETURNING outpoint+ so that
262
- # the check-and-set is atomic at the database level. A concurrent caller that
263
- # wins the race will have already changed the state to 'pending', so the
264
- # second caller's WHERE clause will not match and will return nothing. No
265
- # explicit row-level locking is needed — the UPDATE itself takes the lock.
266
- #
267
- # Legacy rows with +state = NULL AND spendable = TRUE+ are also eligible.
268
- #
269
- # @param outpoints [Array<String>] outpoint identifiers to lock
270
- # @param reference [String] caller-supplied pending reference
271
- # @param no_send [Boolean] true if this is a no_send lock
272
- # @return [Array<String>] outpoints that were actually locked
273
- def lock_utxos(outpoints, reference:, no_send: false)
274
- return [] if outpoints.empty?
275
-
276
- rows = @db[:wallet_outputs]
277
- .where(outpoint: outpoints)
278
- .where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable'))
279
- .returning(:outpoint)
280
- .update(
281
- state: 'pending',
282
- spendable: false,
283
- pending_since: Sequel.lit('NOW()'),
284
- pending_reference: reference,
285
- no_send: no_send ? true : false,
286
- data: Sequel.lit(
287
- "data || jsonb_build_object('state', 'pending', 'pending_since', NOW()::text, " \
288
- "'pending_reference', ?, 'no_send', ?)",
289
- reference, no_send ? true : false
290
- )
291
- )
292
-
293
- rows.map { |r| r[:outpoint] }
294
- end
295
-
296
- # Releases stale pending locks back to +:spendable+.
297
- #
298
- # Any output in +:pending+ state whose +pending_since+ is older than
299
- # +timeout+ seconds is reset to +spendable+ and its pending metadata is
300
- # cleared. Outputs with +no_send = true+ are exempt and remain pending.
301
- # Outputs with +pending_since = NULL+ are also skipped — they are treated
302
- # as freshly locked (NULL means "just acquired but no timestamp yet").
303
- #
304
- # @param timeout [Integer] age in seconds before a lock is considered stale (default 300)
305
- # @return [Integer] number of outputs released
306
- def release_stale_pending!(timeout: 300)
307
- rows = @db[:wallet_outputs]
308
- .where(state: 'pending')
309
- .where(Sequel.lit('no_send IS NOT TRUE'))
310
- .where(Sequel.lit('pending_since IS NOT NULL'))
311
- .where(Sequel.lit('pending_since < (NOW() - INTERVAL ?)', "#{timeout} seconds"))
312
- .returning(:outpoint)
313
- .update(
314
- state: 'spendable',
315
- spendable: true,
316
- pending_since: nil,
317
- pending_reference: nil,
318
- no_send: false,
319
- data: Sequel.lit(
320
- "(data - 'pending_since' - 'pending_reference' - 'no_send') || jsonb_build_object('state', 'spendable')"
321
- )
322
- )
323
-
324
- rows.length
325
- end
326
-
327
- # --- Certificates ---
328
-
329
- def store_certificate(cert_data)
330
- row = certificate_row(cert_data)
331
- @db[:wallet_certificates]
332
- .insert_conflict(
333
- target: %i[type serial_number certifier],
334
- update: { subject: row[:subject], data: row[:data] }
335
- )
336
- .insert(row)
337
- cert_data
338
- end
339
-
340
- def find_certificates(query)
341
- ds = filter_certificates(@db[:wallet_certificates], query)
342
- paginate(ds, query).map { |r| symbolise_keys(r[:data]) }
343
- end
344
-
345
- def count_certificates(query)
346
- filter_certificates(@db[:wallet_certificates], query).count
347
- end
348
-
349
- def delete_certificate(type:, serial_number:, certifier:)
350
- @db[:wallet_certificates]
351
- .where(type: type, serial_number: serial_number, certifier: certifier)
352
- .delete
353
- .positive?
354
- end
355
-
356
- # --- Proofs ---
357
-
358
- def store_proof(txid, bump_hex)
359
- @db[:wallet_proofs]
360
- .insert_conflict(target: :txid, update: { bump_hex: bump_hex })
361
- .insert(txid: txid, bump_hex: bump_hex)
362
- end
363
-
364
- def find_proof(txid)
365
- @db[:wallet_proofs].where(txid: txid).get(:bump_hex)
366
- end
367
-
368
- # --- Transactions ---
369
-
370
- def store_transaction(txid, tx_hex)
371
- @db[:wallet_transactions]
372
- .insert_conflict(target: :txid, update: { tx_hex: tx_hex })
373
- .insert(txid: txid, tx_hex: tx_hex)
374
- end
375
-
376
- def find_transaction(txid)
377
- @db[:wallet_transactions].where(txid: txid).get(:tx_hex)
378
- end
379
-
380
- # --- Settings ---
381
-
382
- def store_setting(key, value)
383
- @db[:wallet_settings]
384
- .insert_conflict(target: :key, update: { value: value })
385
- .insert(key: key, value: value)
386
- end
387
-
388
- def find_setting(key)
389
- @db[:wallet_settings].where(key: key).get(:value)
390
- end
391
-
392
- private
393
-
394
- # --- Row builders ---
395
-
396
- def action_row(data)
397
- {
398
- txid: data[:txid],
399
- labels: Sequel.pg_array(Array(data[:labels]), :text),
400
- data: Sequel.pg_jsonb(data.to_h)
401
- }
402
- end
403
-
404
- def output_row(data)
405
- spendable = data[:spendable] != false # nil treated as spendable, like MemoryStore
406
- state = data[:state]&.to_s
407
- {
408
- outpoint: data[:outpoint],
409
- basket: data[:basket],
410
- tags: Sequel.pg_array(Array(data[:tags]), :text),
411
- spendable: spendable,
412
- state: state,
413
- satoshis: data[:satoshis],
414
- data: Sequel.pg_jsonb(data.to_h)
415
- }
416
- end
417
-
418
- def certificate_row(data)
419
- {
420
- type: data[:type],
421
- serial_number: data[:serial_number],
422
- certifier: data[:certifier],
423
- subject: data[:subject],
424
- data: Sequel.pg_jsonb(data.to_h)
425
- }
426
- end
427
-
428
- # --- Filters ---
429
-
430
- def filter_actions(ds, query)
431
- apply_array_filter(ds, :labels, query[:labels], query[:label_query_mode])
432
- end
433
-
434
- def filter_outputs(ds, query)
435
- ds = ds.where(outpoint: query[:outpoint]) if query[:outpoint]
436
- ds = ds.where(basket: query[:basket]) if query[:basket]
437
- ds = apply_array_filter(ds, :tags, query[:tags], query[:tag_query_mode])
438
- ds = ds.where(Sequel.lit('(state = ? OR (state IS NULL AND spendable = TRUE))', 'spendable')) unless query[:include_spent]
439
- ds
440
- end
441
-
442
- def filter_certificates(ds, query)
443
- ds = ds.where(certifier: query[:certifiers]) if query[:certifiers]
444
- ds = ds.where(type: query[:types]) if query[:types]
445
- ds = ds.where(subject: query[:subject]) if query[:subject]
446
- ds = apply_attributes_filter(ds, query[:attributes]) if query[:attributes]
447
- ds
448
- end
449
-
450
- def apply_array_filter(ds, column, values, mode)
451
- return ds unless values
452
-
453
- array = Sequel.pg_array(Array(values), :text)
454
- op = Sequel.pg_array_op(column)
455
- if mode == 'all'
456
- ds.where(op.contains(array))
457
- else
458
- ds.where(op.overlaps(array))
459
- end
460
- end
461
-
462
- def apply_attributes_filter(ds, attrs)
463
- # Match certificates whose stored fields hash contains every
464
- # key/value pair in +attrs+. Symbol keys stringify when the
465
- # record is serialised to JSONB, so symbol/string keys both work.
466
- fragment = attrs.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
467
- ds.where(Sequel.lit('data->\'fields\' @> ?::jsonb', fragment.to_json))
468
- end
469
-
470
- # --- Pagination ---
471
-
472
- def paginate(ds, query)
473
- ds.order(:id)
474
- .offset(query[:offset] || 0)
475
- .limit(query[:limit] || 10)
476
- .all
477
- end
478
-
479
- # --- JSONB read helpers ---
480
-
481
- # Recursively convert string-keyed hashes to symbol-keyed hashes so
482
- # reads round-trip with MemoryStore's contract. pg_json returns
483
- # string keys by default wrapped in +Sequel::Postgres::JSONBHash+
484
- # / +JSONBArray+, which are +DelegateClass+-based — not Hash/Array
485
- # subclasses — so the case statement uses +to_hash+ / +to_ary+
486
- # coercion to handle them.
487
- def symbolise_keys(obj)
488
- case obj
489
- when Hash
490
- obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolise_keys(v) }
491
- when Array
492
- obj.map { |e| symbolise_keys(e) }
493
- else
494
- return symbolise_keys(obj.to_hash) if obj.respond_to?(:to_hash)
495
- return symbolise_keys(obj.to_ary) if obj.respond_to?(:to_ary)
496
-
497
- obj
498
- end
499
- end
500
- end
501
- end
502
- end