better_auth 0.10.0 → 0.11.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "securerandom"
5
5
  require "time"
6
+ require "monitor"
6
7
 
7
8
  module BetterAuth
8
9
  module Adapters
@@ -13,6 +14,8 @@ module BetterAuth
13
14
  @adapter = adapter
14
15
  @options = options
15
16
  @hooks = DatabaseHooks.new(adapter, options)
17
+ @verification_locks_guard = Mutex.new
18
+ @verification_locks = {}
16
19
  end
17
20
 
18
21
  def create_oauth_user(user, account, context: nil)
@@ -63,7 +66,7 @@ module BetterAuth
63
66
  return false if deleted == false
64
67
 
65
68
  hooks.delete_many([{field: "userId", value: user_id}], "account")
66
- delete_sessions(user_id) if !secondary_storage || options.session[:store_session_in_database]
69
+ delete_user_sessions(user_id)
67
70
  deleted
68
71
  end
69
72
 
@@ -143,20 +146,24 @@ module BetterAuth
143
146
  hooks.delete([{field: "token", value: token}], "session")
144
147
  end
145
148
 
146
- def delete_sessions(user_id_or_tokens)
149
+ def delete_user_sessions(user_id)
147
150
  if secondary_storage
148
- if user_id_or_tokens.is_a?(Array)
149
- user_id_or_tokens.each { |token| secondary_storage.delete(token) }
150
- else
151
- active_session_entries(user_id_or_tokens).each { |entry| secondary_storage.delete(entry["token"]) }
152
- secondary_storage.delete(active_key(user_id_or_tokens))
153
- end
151
+ active_session_entries(user_id).each { |entry| secondary_storage.delete(entry["token"]) }
152
+ secondary_storage.delete(active_key(user_id))
153
+ return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
154
+ end
155
+
156
+ hooks.delete_many([{field: "userId", value: user_id}], "session")
157
+ end
158
+
159
+ def delete_sessions(tokens)
160
+ session_tokens = Array(tokens)
161
+ if secondary_storage
162
+ session_tokens.each { |token| secondary_storage.delete(token) }
154
163
  return if !options.session[:store_session_in_database] || options.session[:preserve_session_in_database]
155
164
  end
156
165
 
157
- field = user_id_or_tokens.is_a?(Array) ? "token" : "userId"
158
- operator = user_id_or_tokens.is_a?(Array) ? "in" : nil
159
- hooks.delete_many([{field: field, value: user_id_or_tokens, operator: operator}], "session")
166
+ hooks.delete_many([{field: "token", value: session_tokens, operator: "in"}], "session")
160
167
  end
161
168
 
162
169
  def delete_accounts(user_id)
@@ -167,6 +174,26 @@ module BetterAuth
167
174
  hooks.delete([{field: "id", value: account_id}], "account")
168
175
  end
169
176
 
177
+ # Removes access established before control of an email address was proven.
178
+ # The user is intentionally left unverified until the caller completes the
179
+ # proof transition and may safely mint a replacement session.
180
+ def revoke_unproven_account_access(user_id)
181
+ adapter.transaction do
182
+ user = find_user_by_id(user_id)
183
+ next unless user && !user["emailVerified"]
184
+
185
+ find_accounts(user_id).each do |account|
186
+ next unless account["providerId"] == "credential"
187
+
188
+ deleted = delete_account(account.fetch("id"))
189
+ raise Error, "Credential account revocation was vetoed" if deleted == false
190
+ end
191
+
192
+ deleted_sessions = delete_user_sessions(user_id)
193
+ raise Error, "Session revocation was vetoed" if deleted_sessions == false
194
+ end
195
+ end
196
+
170
197
  def find_oauth_user(email, account_id, provider_id)
171
198
  account = find_account_with_user(account_id, provider_id)
172
199
  if account
@@ -277,6 +304,56 @@ module BetterAuth
277
304
  values.first
278
305
  end
279
306
 
307
+ def consume_verification_value(identifier)
308
+ identifiers = verification_identifiers(identifier)
309
+ consumed = if secondary_storage && !verification_store_in_database?
310
+ consume_secondary_verification(identifiers)
311
+ else
312
+ consume_database_verification(identifiers)
313
+ end
314
+
315
+ invalidate_secondary_verification(identifiers, consumed) if consumed && secondary_storage && verification_store_in_database?
316
+ return nil unless consumed
317
+
318
+ expires_at = normalize_time(consumed["expiresAt"] || consumed[:expiresAt])
319
+ (expires_at > Time.now) ? normalize_verification_dates(consumed) : nil
320
+ rescue ArgumentError, TypeError
321
+ nil
322
+ end
323
+
324
+ def reserve_verification_value(data)
325
+ payload = stringify_keys(data)
326
+ identifier = payload.fetch("identifier")
327
+ stored_identifier = processed_verification_identifier(identifier)
328
+ reservation_id = Crypto.sha256("reserve:#{identifier}", encoding: :base64url)
329
+ verification = timestamps.merge(
330
+ "id" => reservation_id,
331
+ "identifier" => stored_identifier,
332
+ "value" => payload.fetch("value"),
333
+ "expiresAt" => normalize_time(payload.fetch("expiresAt"))
334
+ )
335
+
336
+ if secondary_storage && !verification_store_in_database?
337
+ return reserve_secondary_verification(verification)
338
+ end
339
+
340
+ adapter.consume_one(
341
+ model: "verification",
342
+ where: [
343
+ {field: "id", value: reservation_id},
344
+ {field: "expiresAt", value: Time.now, operator: "lt"}
345
+ ]
346
+ )
347
+ created = adapter.create_if_absent(
348
+ model: "verification",
349
+ data: verification,
350
+ conflict_field: "id",
351
+ force_allow_id: true
352
+ )
353
+ store_reserved_verification(verification) if created && secondary_storage
354
+ created
355
+ end
356
+
280
357
  def delete_verification_value(id)
281
358
  if secondary_storage
282
359
  stored_identifier = secondary_storage.get(verification_id_key(id))
@@ -325,6 +402,106 @@ module BetterAuth
325
402
 
326
403
  private
327
404
 
405
+ def verification_identifiers(identifier)
406
+ stored_identifier = processed_verification_identifier(identifier)
407
+ storage_option = verification_storage_option(identifier)
408
+ if storage_option && storage_option.to_s != "plain" && stored_identifier != identifier.to_s
409
+ [stored_identifier, identifier.to_s]
410
+ else
411
+ [stored_identifier]
412
+ end
413
+ end
414
+
415
+ def consume_secondary_verification(identifiers)
416
+ identifiers.each do |stored_identifier|
417
+ key = verification_key(stored_identifier)
418
+ raw = if secondary_storage.respond_to?(:get_and_delete)
419
+ secondary_storage.get_and_delete(key)
420
+ elsif secondary_storage.respond_to?(:getAndDelete)
421
+ secondary_storage.getAndDelete(key)
422
+ else
423
+ raise Error, "Secondary storage must implement atomic `get_and_delete`/`getAndDelete`, or verification values must be database-backed"
424
+ end
425
+ consumed = begin
426
+ parsed = parse_storage(raw)
427
+ parsed ? normalize_verification_dates(parsed) : nil
428
+ rescue ArgumentError, TypeError
429
+ nil
430
+ end
431
+ next unless consumed
432
+
433
+ invalidate_secondary_verification(identifiers, consumed)
434
+ return consumed
435
+ end
436
+ nil
437
+ end
438
+
439
+ def reserve_secondary_verification(verification)
440
+ key = verification_key(verification.fetch("identifier"))
441
+ ttl_seconds = ttl(millis(verification.fetch("expiresAt")))
442
+ return false unless ttl_seconds.positive?
443
+
444
+ value = JSON.generate(normalize_verification_dates(verification))
445
+ created = if secondary_storage.respond_to?(:set_if_absent)
446
+ secondary_storage.set_if_absent(key, value, ttl_seconds)
447
+ elsif secondary_storage.respond_to?(:setIfAbsent)
448
+ secondary_storage.setIfAbsent(key, value, ttl_seconds)
449
+ else
450
+ raise Error, "Secondary storage must implement atomic `set_if_absent`/`setIfAbsent`, or verification values must be database-backed"
451
+ end
452
+ return false unless created
453
+ true
454
+ end
455
+
456
+ def consume_database_verification(identifiers)
457
+ identifiers.each do |stored_identifier|
458
+ consumed = with_verification_lock(verification_key(stored_identifier)) do
459
+ adapter.transaction do |transaction_adapter|
460
+ where = [{field: "identifier", value: stored_identifier}]
461
+ latest = transaction_adapter.find_many(
462
+ model: "verification",
463
+ where: where,
464
+ sort_by: {field: "createdAt", direction: "desc"},
465
+ limit: 1
466
+ ).first
467
+ next nil unless latest
468
+ next nil if verification_delete_vetoed?(latest)
469
+
470
+ row = transaction_adapter.consume_one(
471
+ model: "verification",
472
+ where: [{field: "id", value: latest.fetch("id")}]
473
+ )
474
+ next nil unless row
475
+
476
+ transaction_adapter.delete_many(model: "verification", where: where)
477
+ run_verification_delete_after(row)
478
+ row
479
+ end
480
+ end
481
+ return consumed if consumed
482
+ end
483
+ nil
484
+ end
485
+
486
+ def invalidate_secondary_verification(identifiers, consumed)
487
+ identifiers.each { |stored_identifier| secondary_storage.delete(verification_key(stored_identifier)) }
488
+ id = consumed && (consumed["id"] || consumed[:id])
489
+ secondary_storage.delete(verification_id_key(id)) if id
490
+ end
491
+
492
+ def verification_delete_vetoed?(entity)
493
+ hooks.send(:before_hooks, "verification", :delete).any? { |hook| hook.call(entity, nil) == false }
494
+ end
495
+
496
+ def run_verification_delete_after(entity)
497
+ hooks.send(:after_hooks, "verification", :delete).each { |hook| hook.call(entity, nil) }
498
+ end
499
+
500
+ def with_verification_lock(key)
501
+ lock = @verification_locks_guard.synchronize { @verification_locks[key] ||= Monitor.new }
502
+ lock.synchronize { yield }
503
+ end
504
+
328
505
  def secondary_storage
329
506
  options.secondary_storage
330
507
  end
@@ -527,6 +704,15 @@ module BetterAuth
527
704
  normalized
528
705
  end
529
706
 
707
+ def store_reserved_verification(verification)
708
+ normalized = normalize_verification_dates(verification)
709
+ ttl_seconds = ttl(millis(normalized["expiresAt"]))
710
+ return normalized unless ttl_seconds.positive?
711
+
712
+ secondary_storage.set(verification_key(normalized["identifier"]), JSON.generate(normalized), ttl_seconds)
713
+ normalized
714
+ end
715
+
530
716
  def read_verification(identifier)
531
717
  normalize_verification_dates(parse_storage(secondary_storage.get(verification_key(identifier))))
532
718
  end
@@ -58,6 +58,16 @@ module BetterAuth
58
58
  end
59
59
  end
60
60
  end
61
+
62
+ def join_limit(config)
63
+ value = config[:limit]
64
+ return 100 if value.nil?
65
+
66
+ parsed = Integer(value)
67
+ parsed.positive? ? parsed : 100
68
+ rescue ArgumentError, TypeError
69
+ 100
70
+ end
61
71
  end
62
72
  end
63
73
  end
@@ -2,23 +2,52 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "time"
5
+ require "monitor"
5
6
 
6
7
  module BetterAuth
7
8
  module Adapters
8
9
  class Memory < Base
9
10
  include JoinSupport
10
11
 
12
+ LOCK_REGISTRY_GUARD = Mutex.new
13
+ LOCKS_BY_DATABASE = {}
14
+
15
+ class << self
16
+ def lock_for(database)
17
+ LOCK_REGISTRY_GUARD.synchronize do
18
+ LOCKS_BY_DATABASE[database.object_id] ||= Monitor.new
19
+ end
20
+ end
21
+ end
22
+
11
23
  attr_reader :db
12
24
 
13
25
  def initialize(options, db = nil)
14
26
  super(options)
15
27
  @db = db || build_db
28
+ @lock = self.class.lock_for(@db)
16
29
  end
17
30
 
18
31
  def create(model:, data:, force_allow_id: false)
19
- model = model.to_s
20
- table_for(model) << transform_input(model, data, "create", force_allow_id)
21
- table_for(model).last
32
+ @lock.synchronize do
33
+ model = model.to_s
34
+ table_for(model) << transform_input(model, data, "create", force_allow_id)
35
+ table_for(model).last
36
+ end
37
+ end
38
+
39
+ def create_if_absent(model:, data:, conflict_field: "id", force_allow_id: true)
40
+ @lock.synchronize do
41
+ model = model.to_s
42
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
43
+ field = atomic_schema_field(fields, conflict_field)
44
+ input = transform_input(model, data, "create", force_allow_id)
45
+ raise APIError.new("BAD_REQUEST", message: "Missing conflict field #{conflict_field}") unless input.key?(field)
46
+ next false if table_for(model).any? { |record| record[field] == input[field] }
47
+
48
+ table_for(model) << input
49
+ true
50
+ end
22
51
  end
23
52
 
24
53
  def find_one(model:, where: [], select: nil, join: nil)
@@ -26,34 +55,42 @@ module BetterAuth
26
55
  end
27
56
 
28
57
  def find_many(model:, where: [], sort_by: nil, limit: nil, offset: nil, select: nil, join: nil)
29
- model = model.to_s
30
- records = table_for(model).select { |record| matches_where?(record, where || []) }.map(&:dup)
31
- records = records.map { |record| apply_join(model, record, join) } if join
32
- records = sort_records(model, records, sort_by) if sort_by
33
- records = records.drop(offset.to_i) if offset
34
- records = records.first(limit.to_i) if limit
35
- records = records.map { |record| select_fields(model, record, select) } if select && !select.empty?
36
- records
58
+ @lock.synchronize do
59
+ model = model.to_s
60
+ records = table_for(model).select { |record| matches_where?(record, where || []) }.map(&:dup)
61
+ records = records.map { |record| apply_join(model, record, join) } if join
62
+ records = sort_records(model, records, sort_by) if sort_by
63
+ records = records.drop(offset.to_i) if offset
64
+ records = records.first(limit.to_i) if limit
65
+ records = records.map { |record| select_fields(model, record, select, join: join) } if select && !select.empty?
66
+ records
67
+ end
37
68
  end
38
69
 
39
70
  def update(model:, where:, update:)
40
- model = model.to_s
41
- ensure_update_input_has_fields!(model, update)
42
- records = table_for(model).select { |record| matches_where?(record, where || []) }
43
- data = transform_input(model, update, "update", true)
44
- ensure_update_data!(data)
45
- records.each { |record| record.merge!(data) }
46
- records.first
71
+ @lock.synchronize do
72
+ model = model.to_s
73
+ next nil if Array(where).empty?
74
+
75
+ ensure_update_input_has_fields!(model, update)
76
+ records = table_for(model).select { |record| matches_where?(record, where) }
77
+ data = transform_input(model, update, "update", true)
78
+ ensure_update_data!(data)
79
+ records.each { |record| record.merge!(data) }
80
+ records.first
81
+ end
47
82
  end
48
83
 
49
84
  def update_many(model:, where:, update:)
50
- model = model.to_s
51
- ensure_update_input_has_fields!(model, update)
52
- records = table_for(model).select { |record| matches_where?(record, where || []) }
53
- data = transform_input(model, update, "update", true)
54
- ensure_update_data!(data)
55
- records.each { |record| record.merge!(data) }
56
- records.length
85
+ @lock.synchronize do
86
+ model = model.to_s
87
+ ensure_update_input_has_fields!(model, update)
88
+ records = table_for(model).select { |record| matches_where?(record, where || []) }
89
+ data = transform_input(model, update, "update", true)
90
+ ensure_update_data!(data)
91
+ records.each { |record| record.merge!(data) }
92
+ records.length
93
+ end
57
94
  end
58
95
 
59
96
  def delete(model:, where:)
@@ -62,26 +99,103 @@ module BetterAuth
62
99
  end
63
100
 
64
101
  def delete_many(model:, where:)
65
- table = table_for(model)
66
- matches = table.select { |record| matches_where?(record, where || []) }
67
- @db[model.to_s] = table.reject { |record| matches.include?(record) }
68
- matches.length
102
+ @lock.synchronize do
103
+ table = table_for(model)
104
+ matches = table.select { |record| matches_where?(record, where || []) }
105
+ @db[model.to_s] = table.reject { |record| matches.include?(record) }
106
+ matches.length
107
+ end
69
108
  end
70
109
 
71
110
  def count(model:, where: nil)
72
111
  find_many(model: model, where: where || []).length
73
112
  end
74
113
 
114
+ def consume_one(model:, where:)
115
+ @lock.synchronize do
116
+ table = table_for(model)
117
+ index = table.index { |record| matches_where?(record, where || []) }
118
+ index ? table.delete_at(index) : nil
119
+ end
120
+ end
121
+
122
+ def increment_one(model:, where:, increment:, set: nil, allow_server_managed: false)
123
+ @lock.synchronize do
124
+ model = model.to_s
125
+ increments = normalized_increments(model, increment, allow_server_managed)
126
+ assignments = normalized_increment_set(model, set)
127
+ raise APIError.new("BAD_REQUEST", message: "increment_one requires a non-empty increment or set") if increments.empty? && assignments.empty?
128
+
129
+ target = table_for(model).find { |record| matches_where?(record, where || []) }
130
+ next nil unless target
131
+
132
+ increments.each do |field, delta|
133
+ current = target[field]
134
+ target[field] = (current.is_a?(Numeric) ? current : 0) + delta
135
+ end
136
+ target.merge!(assignments)
137
+ target
138
+ end
139
+ end
140
+
75
141
  def transaction
76
- snapshot = Marshal.load(Marshal.dump(db))
77
- yield self
78
- rescue
79
- @db = snapshot
80
- raise
142
+ return yield active_transaction_adapter if active_transaction_adapter
143
+
144
+ @lock.synchronize do
145
+ snapshot = transaction_snapshot(db)
146
+ begin
147
+ with_transaction_context(self) { yield self }
148
+ rescue
149
+ db.replace(snapshot)
150
+ raise
151
+ end
152
+ end
81
153
  end
82
154
 
83
155
  private
84
156
 
157
+ def transaction_snapshot(value, copies = {})
158
+ object_id = value.object_id
159
+ return copies.fetch(object_id) if copies.key?(object_id)
160
+
161
+ copy = case value
162
+ when Hash
163
+ value.dup.tap do |hash|
164
+ hash.clear
165
+ copies[object_id] = hash
166
+ if value.default_proc
167
+ hash.default_proc = value.default_proc
168
+ else
169
+ hash.default = transaction_snapshot(value.default, copies)
170
+ end
171
+ value.each do |key, child|
172
+ hash[transaction_snapshot(key, copies)] = transaction_snapshot(child, copies)
173
+ end
174
+ end
175
+ when Array
176
+ value.dup.tap do |array|
177
+ array.clear
178
+ copies[object_id] = array
179
+ value.each { |child| array << transaction_snapshot(child, copies) }
180
+ end
181
+ when Struct
182
+ value.dup.tap do |struct|
183
+ copies[object_id] = struct
184
+ value.each_pair do |member, child|
185
+ struct[member] = transaction_snapshot(child, copies)
186
+ end
187
+ end
188
+ when String, Time
189
+ value.dup.tap { |object| copies[object_id] = object }
190
+ when Proc, Method, UnboundMethod, NilClass, TrueClass, FalseClass, Symbol, Numeric
191
+ return value
192
+ else
193
+ raise TypeError, "Unsupported Memory transaction snapshot value: #{value.class}"
194
+ end
195
+ copy.freeze if value.frozen?
196
+ copy
197
+ end
198
+
85
199
  def build_db
86
200
  Schema.auth_tables(options).keys.to_h { |model| [model, []] }
87
201
  end
@@ -141,6 +255,40 @@ module BetterAuth
141
255
  raise APIError.new("BAD_REQUEST", message: "No fields to update") unless has_updatable_field
142
256
  end
143
257
 
258
+ def normalized_increments(model, increment, allow_server_managed)
259
+ raise APIError.new("BAD_REQUEST", message: "increment must be a Hash") unless increment.is_a?(Hash)
260
+
261
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
262
+ increment.each_with_object({}) do |(field, delta), result|
263
+ logical_field = atomic_schema_field(fields, field)
264
+ attributes = fields[logical_field]
265
+ valid_field = attributes && logical_field != "id" && attributes[:type] == "number"
266
+ valid_field = false if attributes && attributes[:input] == false && allow_server_managed != true
267
+ unless valid_field
268
+ raise APIError.new("BAD_REQUEST", message: "Invalid increment field #{field}; expected a mutable numeric field")
269
+ end
270
+ if !delta.is_a?(Numeric) || (delta.respond_to?(:finite?) && !delta.finite?)
271
+ raise APIError.new("BAD_REQUEST", message: "Increment delta for #{field} must be numeric")
272
+ end
273
+
274
+ result[logical_field] = delta
275
+ end
276
+ end
277
+
278
+ def normalized_increment_set(model, set)
279
+ return {} if set.nil? || set.empty?
280
+
281
+ ensure_update_input_has_fields!(model, set)
282
+ transform_input(model, set, "update", true)
283
+ end
284
+
285
+ def atomic_schema_field(fields, field)
286
+ candidate = Schema.storage_key(field)
287
+ return candidate if fields.key?(candidate)
288
+
289
+ fields.find { |logical, attributes| Schema.storage_key(attributes[:field_name] || logical) == candidate }&.first
290
+ end
291
+
144
292
  def generated_id(model)
145
293
  generator = options.advanced.dig(:database, :generate_id)
146
294
  return generator.call.to_s if generator.respond_to?(:call)
@@ -166,16 +314,9 @@ module BetterAuth
166
314
  clauses = Array(where)
167
315
  return true if clauses.empty?
168
316
 
169
- result = evaluate_clause(record, clauses.first)
170
- clauses.each do |clause|
171
- clause_result = evaluate_clause(record, clause)
172
- if fetch_key(clause, :connector).to_s.upcase == "OR"
173
- result ||= clause_result
174
- else
175
- result &&= clause_result
176
- end
177
- end
178
- result
317
+ and_clauses, or_clauses = grouped_where_clauses(clauses)
318
+ and_clauses.all? { |clause| evaluate_clause(record, clause) } &&
319
+ (or_clauses.empty? || or_clauses.any? { |clause| evaluate_clause(record, clause) })
179
320
  end
180
321
 
181
322
  def evaluate_clause(record, clause)
@@ -301,9 +442,13 @@ module BetterAuth
301
442
  value.nil? ? "" : value
302
443
  end
303
444
 
304
- def select_fields(_model, record, select)
445
+ def select_fields(model, record, select, join: nil)
305
446
  fields = Array(select).map { |field| Schema.storage_key(field) }
306
- record.slice(*fields)
447
+ selected = record.slice(*fields)
448
+ normalized_join(model, join).each_key do |join_model|
449
+ selected[join_model] = record[join_model] if record.key?(join_model)
450
+ end
451
+ selected
307
452
  end
308
453
 
309
454
  def apply_join(model, record, join)
@@ -326,16 +471,6 @@ module BetterAuth
326
471
  config[:relation] == "one-to-one" || config[:unique] == true
327
472
  end
328
473
 
329
- def join_limit(config)
330
- value = config[:limit]
331
- return 100 if value.nil?
332
-
333
- parsed = Integer(value)
334
- parsed.positive? ? parsed : 100
335
- rescue ArgumentError, TypeError
336
- 100
337
- end
338
-
339
474
  def inferred_join_config(model, join_model)
340
475
  foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
341
476
  reference_model_matches?(attributes, model)
@@ -17,16 +17,20 @@ module BetterAuth
17
17
  end
18
18
 
19
19
  def transaction
20
+ return yield active_transaction_adapter if active_transaction_adapter
20
21
  return super unless connection.respond_to?(:transaction)
21
22
 
22
- connection.transaction { yield self }
23
+ connection.transaction { with_transaction_context(self) { yield self } }
23
24
  end
24
25
 
25
26
  private
26
27
 
27
- def execute(sql, params)
28
+ def execute(sql, params, affected_rows_result: false)
28
29
  if connection.respond_to?(:fetch)
29
- connection.fetch(sql, *params).all.map { |row| stringify_row(row) }
30
+ dataset = connection.fetch(sql, *params)
31
+ return dataset.delete if affected_rows_result
32
+
33
+ dataset.all.map { |row| stringify_row(row) }
30
34
  else
31
35
  super
32
36
  end