activeitem 0.0.7 → 0.0.9

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0449e0de4aa951adcf2bdd085d145a0673439115fd8a51b7de275f60c461ecd9'
4
- data.tar.gz: 5e580ffadfd8ee4b22fbd6f4c54b934330c832383baf07f9c601e64e3fd3a99a
3
+ metadata.gz: b243ed910a2d03058939e87540ae8db73306ce0b550497c9a2f7aab8cf4cb1b4
4
+ data.tar.gz: ebb28b886aa8f1e7fe45c0efe1dd760af15a729f434e5beb5075952bb059fee3
5
5
  SHA512:
6
- metadata.gz: 4ad8dbfafc70f15ade76a5e030e505e8af3e7b3186d355ac5d16de97843f905f7789471bb04e706ef84fb012fa8de3d0c361973514c33ecc5f534efd9e4f8c3b
7
- data.tar.gz: ddc7e4f5bf1ab8a814e2d3e58c103d01d2446f85599802925ff7e624d95ffd8ae559e66cc0b7c941adfd160a0f1c166cfac77104f5e84f48dd5dd7c570f58a58
6
+ metadata.gz: 18250436726865a0397d3db991a911356f41a59b7d5b0542e56d8e171e27094d3c19d43fb73a6c42b6d1b705544432cc3f928f7bbf70afdd69ae3dbc2c8f78b3
7
+ data.tar.gz: 17677be007f18965b1206c1fb8274835dc86b923b54a0f43bd48f20750edd2eb052f38412d86357e28ef614207f2a24fb614e3c9ecd4878149bd8aa5ab7c61a0
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.9
4
+
5
+ ### Fixed
6
+
7
+ - **Pagination cursor loss in `paginated_query_with_index`** — when the 2x overfetch returned all remaining items in a single DynamoDB response (no `LastEvaluatedKey`), the method returned `has_more: false` despite having silently trimmed items. Now builds a synthetic `ExclusiveStartKey` from the last returned item's attributes (table PK + GSI partition/sort keys) to ensure correct pagination.
8
+
9
+ ## 0.0.8
10
+
11
+ ### Added
12
+
13
+ - **Array partition key fan-out** — `where(partition_key: [val1, val2, ...])` now queries each partition in parallel and merges results, instead of raising `ArgumentError`
14
+ - Works with `.to_a`, `.page(cursor, per_page:)`, `.count`, `.order(:desc)`, `.not(...)`, and all other chainable methods
15
+ - Pagination uses a `sort_val|id` cursor format for cross-partition consistency
16
+ - Thread-pooled execution (max 10 concurrent queries) matching existing `preload_has_many_counts` pattern
17
+
3
18
  ## 0.0.7
4
19
 
5
20
  ### Added
data/certs/stowzilla.pem CHANGED
@@ -1,31 +1,26 @@
1
1
  -----BEGIN CERTIFICATE-----
2
- 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
- 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
- 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
- NjE1MDMzMlowRDESMBAGA1UEAwwJc3Rvd3ppbGxhMRkwFwYKCZImiZPyLGQBGRYJ
6
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- MBaAFJdJFQTOcHjR+03ZWZ7p9nnCVRBRMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
20
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- +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
- 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
- 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
31
26
  -----END CERTIFICATE-----
@@ -507,10 +507,20 @@ module ActiveItem
507
507
  return [[], nil] if conditions[:_empty]
508
508
 
509
509
  normalized_conditions = normalize_conditions(conditions)
510
- exclusive_start_key = decode_cursor(cursor)
511
510
 
512
511
  effective_index = index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
513
512
 
513
+ # Array partition key fan-out uses a simple string cursor (sort_val|id), not DynamoDB LastEvaluatedKey
514
+ if effective_index && normalized_conditions.any? && normalized_conditions.values.first.is_a?(Array)
515
+ index_config = resolved_model.indexes[effective_index] || {}
516
+ ruby_partition_key = normalized_conditions.keys.first.to_s
517
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
518
+ return fanout_paginated_query(effective_index, normalized_conditions, index_config, dynamo_partition_key,
519
+ normalized_conditions.values.first, cursor, per_page)
520
+ end
521
+
522
+ exclusive_start_key = decode_cursor(cursor)
523
+
514
524
  if effective_index && normalized_conditions.any?
515
525
  paginated_query_with_index(effective_index, normalized_conditions, exclusive_start_key, per_page)
516
526
  else
@@ -651,12 +661,24 @@ module ActiveItem
651
661
 
652
662
  # Trim to requested page size and determine next cursor
653
663
  if collected_items.length > per_page
654
- [collected_items.take(per_page), encode_cursor(last_evaluated_key || current_cursor)]
664
+ next_cursor = last_evaluated_key || build_synthetic_cursor(collected_items[per_page - 1], idx_name, index_config)
665
+ [collected_items.take(per_page), encode_cursor(next_cursor)]
655
666
  else
656
667
  [collected_items, encode_cursor(last_evaluated_key)]
657
668
  end
658
669
  end
659
670
 
671
+ # Build a synthetic ExclusiveStartKey from an item when DynamoDB didn't paginate
672
+ # but we have more items than per_page (overfetch returned everything in one shot).
673
+ # For GSI queries, DynamoDB requires: table PK + index partition key + index sort key (if any).
674
+ def build_synthetic_cursor(item, _idx_name, index_config)
675
+ pk_dynamo = resolved_model.to_dynamo_key(resolved_model.primary_key.to_s)
676
+ cursor = { pk_dynamo => item.id }
677
+ cursor[index_config[:partition_key].to_s] = item.send(resolved_model.from_dynamo_key(index_config[:partition_key].to_s)) if index_config[:partition_key]
678
+ cursor[index_config[:sort_key].to_s] = item.send(resolved_model.from_dynamo_key(index_config[:sort_key].to_s)) if index_config[:sort_key]
679
+ cursor
680
+ end
681
+
660
682
  # Execute paginated scan
661
683
  # Keeps fetching until we have per_page items that pass the filter
662
684
  def paginated_scan_with_conditions(normalized_conditions, exclusive_start_key, per_page)
@@ -755,6 +777,9 @@ module ActiveItem
755
777
 
756
778
  effective_index = (index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions) if normalized_conditions.any?)
757
779
 
780
+ # Array partition key: fan-out count queries in parallel
781
+ return fanout_count_query(effective_index, normalized_conditions) if effective_index && normalized_conditions.any? && normalized_conditions.values.first.is_a?(Array)
782
+
758
783
  total = 0
759
784
  exclusive_start_key = nil
760
785
 
@@ -1034,7 +1059,7 @@ module ActiveItem
1034
1059
  index_config = resolved_model.indexes[idx_name] || {}
1035
1060
  dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
1036
1061
 
1037
- raise ArgumentError, 'Array values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Array)
1062
+ return fanout_query(idx_name, normalized_conditions, index_config, dynamo_partition_key, partition_value) if partition_value.is_a?(Array)
1038
1063
 
1039
1064
  raise ArgumentError, 'Range values not supported for partition key queries. Use scan instead.' if partition_value.is_a?(Range)
1040
1065
 
@@ -1120,6 +1145,281 @@ module ActiveItem
1120
1145
  all_items
1121
1146
  end
1122
1147
 
1148
+ # Fan-out parallel queries when partition key is an Array.
1149
+ # Queries each partition key in parallel, merges and optionally sorts results.
1150
+ def fanout_query(idx_name, _normalized_conditions, index_config, dynamo_partition_key, partition_values)
1151
+ return [] if partition_values.empty?
1152
+
1153
+ sort_key = index_config[:sort_key]&.to_s
1154
+ remaining_conditions = conditions.to_a[1..]
1155
+ filter_parts, filter_names, filter_values = build_fanout_filters(sort_key, remaining_conditions)
1156
+
1157
+ all_items = []
1158
+ mutex = Mutex.new
1159
+ max_concurrency = 10
1160
+
1161
+ partition_values.each_slice(max_concurrency) do |batch|
1162
+ threads = batch.map do |pk_val|
1163
+ Thread.new do
1164
+ params = {
1165
+ table_name: resolved_model.table_name,
1166
+ index_name: idx_name,
1167
+ key_condition_expression: '#pk = :pk_val',
1168
+ expression_attribute_names: { '#pk' => dynamo_partition_key }.merge(filter_names),
1169
+ expression_attribute_values: { ':pk_val' => pk_val }.merge(filter_values)
1170
+ }
1171
+
1172
+ apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1173
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1174
+ params[:limit] = limit_value if limit_value
1175
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
1176
+ apply_projection_expression!(params)
1177
+
1178
+ items = []
1179
+ exclusive_start_key = nil
1180
+ loop do
1181
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1182
+ response = resolved_model.dynamodb.query(params)
1183
+ items.concat(response.items.map { |item| resolved_model.instantiate(item) })
1184
+ exclusive_start_key = response.last_evaluated_key
1185
+ break unless exclusive_start_key
1186
+ break if limit_value && items.length >= limit_value
1187
+ end
1188
+ mutex.synchronize { all_items.concat(items) }
1189
+ end
1190
+ end
1191
+ threads.each(&:join)
1192
+ end
1193
+
1194
+ all_items = apply_ilike_filter(all_items)
1195
+ all_items = sort_fanout_results(all_items, sort_key)
1196
+ all_items = all_items.first(limit_value) if limit_value
1197
+ all_items
1198
+ end
1199
+
1200
+ # Fan-out paginated query across multiple partition keys.
1201
+ # Uses a composite cursor that encodes sort_key|id position for cross-partition pagination.
1202
+ def fanout_paginated_query(idx_name, _normalized_conditions, index_config, dynamo_partition_key, partition_values,
1203
+ cursor, per_page)
1204
+ return [[], nil] if partition_values.empty?
1205
+
1206
+ sort_key = index_config[:sort_key]&.to_s
1207
+ remaining_conditions = conditions.to_a[1..]
1208
+ filter_parts, filter_names, filter_values = build_fanout_filters(sort_key, remaining_conditions)
1209
+ cursor_sort_val, cursor_id = decode_fanout_cursor(cursor)
1210
+
1211
+ all_items = []
1212
+ mutex = Mutex.new
1213
+ max_concurrency = 10
1214
+
1215
+ partition_values.each_slice(max_concurrency) do |batch|
1216
+ threads = batch.map do |pk_val|
1217
+ Thread.new do
1218
+ params = {
1219
+ table_name: resolved_model.table_name,
1220
+ index_name: idx_name,
1221
+ key_condition_expression: '#pk = :pk_val',
1222
+ expression_attribute_names: { '#pk' => dynamo_partition_key }.merge(filter_names),
1223
+ expression_attribute_values: { ':pk_val' => pk_val }.merge(filter_values)
1224
+ }
1225
+
1226
+ apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1227
+
1228
+ # Apply cursor range condition to skip already-seen items
1229
+ if cursor_sort_val && sort_key
1230
+ sk_placeholder = '#fanout_sk'
1231
+ val_placeholder = ':fanout_sk_val'
1232
+ op = order_direction == :desc ? '<=' : '>='
1233
+ params[:key_condition_expression] += " AND #{sk_placeholder} #{op} #{val_placeholder}"
1234
+ params[:expression_attribute_names][sk_placeholder] = sort_key
1235
+ params[:expression_attribute_values][val_placeholder] = cursor_sort_val
1236
+ end
1237
+
1238
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1239
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
1240
+ # Fetch enough to fill a page after merge
1241
+ params[:limit] = per_page + 1
1242
+
1243
+ items = []
1244
+ exclusive_start_key = nil
1245
+ loop do
1246
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1247
+ response = resolved_model.dynamodb.query(params)
1248
+ items.concat(response.items.map { |item| resolved_model.instantiate(item) })
1249
+ exclusive_start_key = response.last_evaluated_key
1250
+ break unless exclusive_start_key
1251
+ break if items.length >= per_page + 1
1252
+ end
1253
+ mutex.synchronize { all_items.concat(items) }
1254
+ end
1255
+ end
1256
+ threads.each(&:join)
1257
+ end
1258
+
1259
+ all_items = apply_ilike_filter(all_items)
1260
+ all_items = sort_fanout_results(all_items, sort_key)
1261
+
1262
+ # Skip items at or before the cursor position
1263
+ if cursor_sort_val && cursor_id
1264
+ all_items = all_items.drop_while do |item|
1265
+ sk_val = if sort_key
1266
+ item.respond_to?(sort_key) ? item.send(sort_key) : item.send(underscore(sort_key))
1267
+ end
1268
+ sk_val ||= item.respond_to?(:created_at) ? item.created_at : nil
1269
+ cmp = compare_with_nil(sk_val, cursor_sort_val)
1270
+ if order_direction == :desc
1271
+ cmp.positive? || (cmp.zero? && item.id >= cursor_id)
1272
+ else
1273
+ cmp.negative? || (cmp.zero? && item.id <= cursor_id)
1274
+ end
1275
+ end
1276
+ end
1277
+
1278
+ page_items = all_items.first(per_page)
1279
+ next_cursor = (encode_fanout_cursor(page_items.last, sort_key) if all_items.length > per_page && page_items.last)
1280
+
1281
+ [page_items, next_cursor]
1282
+ end
1283
+
1284
+ def build_fanout_filters(sort_key, remaining_conditions)
1285
+ filter_parts = []
1286
+ filter_names = {}
1287
+ filter_values = {}
1288
+
1289
+ # Exclude sort key conditions — those go into key_condition_expression
1290
+ non_sort_conditions = remaining_conditions.reject do |k, _|
1291
+ sort_key && (resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key)
1292
+ end
1293
+
1294
+ non_sort_conditions.each_with_index do |(attr, val), idx|
1295
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
1296
+ filter_parts << expr
1297
+ filter_names.merge!(names)
1298
+ filter_values.merge!(values) if values.any?
1299
+ end
1300
+
1301
+ not_conditions.each_with_index do |(attr, val), idx|
1302
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
1303
+ filter_parts << expr
1304
+ filter_names.merge!(names)
1305
+ filter_values.merge!(values) if values.any?
1306
+ end
1307
+
1308
+ [filter_parts, filter_names, filter_values]
1309
+ end
1310
+
1311
+ def apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1312
+ return unless sort_key && remaining_conditions.any?
1313
+
1314
+ sort_condition = remaining_conditions.find do |k, _|
1315
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
1316
+ end
1317
+ return unless sort_condition
1318
+
1319
+ _, sort_value = sort_condition
1320
+ if sort_value.is_a?(Range)
1321
+ range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
1322
+ params[:key_condition_expression] += " AND #{range_condition[:expression]}"
1323
+ params[:expression_attribute_names].merge!(range_condition[:names])
1324
+ params[:expression_attribute_values].merge!(range_condition[:values])
1325
+ else
1326
+ params[:key_condition_expression] += ' AND #sk = :sk_val'
1327
+ params[:expression_attribute_names]['#sk'] = sort_key
1328
+ params[:expression_attribute_values][':sk_val'] = sort_value
1329
+ end
1330
+ end
1331
+
1332
+ def sort_fanout_results(items, sort_key)
1333
+ return items unless sort_key
1334
+
1335
+ sorted = items.sort_by do |item|
1336
+ sk_val = item.respond_to?(sort_key) ? item.send(sort_key) : item.send(underscore(sort_key))
1337
+ sk_val ||= ''
1338
+ if order_direction == :desc
1339
+ [sk_val, item.id].map { |v| v.is_a?(String) ? v : v.to_s }
1340
+ else
1341
+ [sk_val, item.id]
1342
+ end
1343
+ end
1344
+
1345
+ order_direction == :desc ? sorted.reverse : sorted
1346
+ end
1347
+
1348
+ def decode_fanout_cursor(cursor)
1349
+ return [nil, nil] if cursor.nil? || cursor.empty?
1350
+
1351
+ sort_val, id = cursor.split('|', 2)
1352
+ [sort_val, id]
1353
+ rescue StandardError
1354
+ [nil, nil]
1355
+ end
1356
+
1357
+ def encode_fanout_cursor(item, sort_key)
1358
+ sk_val = if sort_key
1359
+ item.respond_to?(sort_key) ? item.send(sort_key) : item.send(underscore(sort_key))
1360
+ end
1361
+ sk_val ||= item.respond_to?(:created_at) ? item.created_at : nil
1362
+ "#{sk_val}|#{item.id}"
1363
+ end
1364
+
1365
+ def compare_with_nil(left, right)
1366
+ return 0 if left.nil? && right.nil?
1367
+ return -1 if left.nil?
1368
+ return 1 if right.nil?
1369
+
1370
+ left <=> right
1371
+ end
1372
+
1373
+ def underscore(str)
1374
+ str.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase
1375
+ end
1376
+
1377
+ def fanout_count_query(idx_name, normalized_conditions)
1378
+ partition_values = normalized_conditions.values.first
1379
+ index_config = resolved_model.indexes[idx_name] || {}
1380
+ ruby_partition_key = normalized_conditions.keys.first.to_s
1381
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
1382
+ sort_key = index_config[:sort_key]&.to_s
1383
+ remaining_conditions = conditions.to_a[1..]
1384
+ filter_parts, filter_names, filter_values = build_fanout_filters(sort_key, remaining_conditions)
1385
+
1386
+ total = 0
1387
+ mutex = Mutex.new
1388
+ max_concurrency = 10
1389
+
1390
+ partition_values.each_slice(max_concurrency) do |batch|
1391
+ threads = batch.map do |pk_val|
1392
+ Thread.new do
1393
+ params = {
1394
+ table_name: resolved_model.table_name,
1395
+ index_name: idx_name,
1396
+ select: 'COUNT',
1397
+ key_condition_expression: '#pk = :pk_val',
1398
+ expression_attribute_names: { '#pk' => dynamo_partition_key }.merge(filter_names),
1399
+ expression_attribute_values: { ':pk_val' => pk_val }.merge(filter_values)
1400
+ }
1401
+
1402
+ apply_sort_key_to_params!(params, sort_key, remaining_conditions)
1403
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1404
+
1405
+ count = 0
1406
+ exclusive_start_key = nil
1407
+ loop do
1408
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1409
+ response = resolved_model.dynamodb.query(params)
1410
+ count += response.count
1411
+ exclusive_start_key = response.last_evaluated_key
1412
+ break unless exclusive_start_key
1413
+ end
1414
+ mutex.synchronize { total += count }
1415
+ end
1416
+ end
1417
+ threads.each(&:join)
1418
+ end
1419
+
1420
+ limit_value ? [total, limit_value].min : total
1421
+ end
1422
+
1123
1423
  def scan_with_conditions_normalized(normalized_conditions)
1124
1424
  filter_parts = []
1125
1425
  filter_values = {}
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.7'
4
+ VERSION = '0.0.9'
5
5
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeitem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -11,37 +11,32 @@ bindir: bin
11
11
  cert_chain:
12
12
  - |
13
13
  -----BEGIN CERTIFICATE-----
14
- MIIFaTCCA1GgAwIBAgIUax444kioC0RHJ7r5iympSbt4GOEwDQYJKoZIhvcNAQEL
15
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- u4RtgGkUD7eJYzJ4QSpNJgr+eUQXMirwn7xPRhBIWIZx4QbnAmZRfNpLjoPRiC9k
26
- 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
- hsLkZ7VxuO0gqfUDKG9DqX4/+xSqNKO8bwYa+nbBtZPjPWFyHQGQWKXOLLFK/VHk
28
- 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
- 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
- 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
- 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
- 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
- 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
- IrWWn4CgEWG8ek/81vvJZ7nBBq+BS11/yi2sx/j/eSArY/5ElM6rEnKrG/E7YnZD
35
- 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
- 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
- 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
- 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
- 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
- +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
- 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
- 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
43
38
  -----END CERTIFICATE-----
44
- date: 2026-06-01 00:00:00.000000000 Z
39
+ date: 2026-06-17 00:00:00.000000000 Z
45
40
  dependencies:
46
41
  - !ruby/object:Gem::Dependency
47
42
  name: activemodel
@@ -188,7 +183,7 @@ licenses:
188
183
  - MIT
189
184
  metadata:
190
185
  homepage_uri: https://github.com/stowzilla/activeitem
191
- source_code_uri: https://github.com/stowzilla/activeitem
186
+ source_code_uri: https://github.com/stowzilla/activeitem/tree/master
192
187
  changelog_uri: https://github.com/stowzilla/activeitem/blob/main/CHANGELOG.md
193
188
  rubygems_mfa_required: 'true'
194
189
  post_install_message:
metadata.gz.sig CHANGED
Binary file