activeadmin_batched_export 0.1.0 → 0.3.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/CODE_OF_CONDUCT.md +14 -26
  4. data/CONTRIBUTING.md +11 -17
  5. data/GOVERNANCE.md +11 -18
  6. data/README.md +46 -13
  7. data/SECURITY.md +10 -13
  8. data/activeadmin_batched_export.gemspec +2 -2
  9. data/app/assets/controllers/activeadmin_batched_export/batched_export_controller.js +136 -96
  10. data/app/assets/javascripts/activeadmin_batched_export/chunk_assembly.mjs +30 -0
  11. data/app/views/active_admin/batched_export/_actions.html.erb +12 -1
  12. data/app/views/active_admin/batched_export/_progress.html.erb +1 -1
  13. data/app/views/active_admin/batched_export/workspace.html.erb +3 -0
  14. data/app/views/active_admin/shared/_download_format_links.html.erb +9 -3
  15. data/config/locales/activeadmin_batched_export.en.yml +7 -2
  16. data/lib/activeadmin/batched_export/chunk_renderer.rb +113 -0
  17. data/lib/activeadmin/batched_export/configuration.rb +5 -3
  18. data/lib/activeadmin/batched_export/controller_methods.rb +115 -113
  19. data/lib/activeadmin/batched_export/engine.rb +37 -1
  20. data/lib/activeadmin/batched_export/errors.rb +9 -0
  21. data/lib/activeadmin/batched_export/export_cursor.rb +56 -0
  22. data/lib/activeadmin/batched_export/keyset_page.rb +84 -0
  23. data/lib/activeadmin/batched_export/resource_extension.rb +11 -0
  24. data/lib/activeadmin/batched_export/row_sanitizer.rb +11 -0
  25. data/lib/activeadmin/batched_export/snapshot_page.rb +140 -0
  26. data/lib/activeadmin/batched_export/snapshot_row.rb +41 -0
  27. data/lib/activeadmin/batched_export/styles.rb +1 -0
  28. data/lib/activeadmin/batched_export/version.rb +1 -1
  29. metadata +13 -5
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "activeadmin/batched_export/errors"
5
+ require "activeadmin/batched_export/export_cursor"
6
+ require "activeadmin/batched_export/keyset_page"
7
+ require "activeadmin/batched_export/snapshot_row"
8
+
9
+ module ActiveAdmin
10
+ module BatchedExport
11
+ class SnapshotPage
12
+ HEADER = "X-Batched-Export-Snapshot"
13
+ INSERT_SLICE = 500
14
+ Page = Struct.new(:records, :snapshot_token, :next_cursor, :first_page)
15
+
16
+ def self.fetch(collection, model:, field:, direction:, cursor:, snapshot_param:, limit:)
17
+ if snapshot_param.present?
18
+ resume(collection, model: model, field: field, direction: direction,
19
+ cursor: cursor, snapshot_param: snapshot_param, limit: limit)
20
+ elsif SnapshotRow.available? && cursor.nil?
21
+ freeze_first(collection, model: model, field: field, direction: direction, limit: limit)
22
+ else
23
+ live(collection, model: model, field: field, direction: direction, cursor: cursor, limit: limit)
24
+ end
25
+ end
26
+
27
+ def self.freeze_first(collection, model:, field:, direction:, limit:)
28
+ SnapshotRow.expire_stale!(ttl: BatchedExport.config.snapshot_ttl)
29
+ # Full id list in process memory on freeze. Stream into insert_all when that ceiling matters.
30
+ ids = KeysetPage.ordered(collection, model: model, field: field, direction: direction)
31
+ .pluck(model.primary_key)
32
+ return Page.new(records: [], snapshot_token: nil, next_cursor: nil, first_page: true) if ids.empty?
33
+
34
+ token = insert_ids(ids, model.name)
35
+ walk(collection, model: model, field: field, direction: direction,
36
+ token: token, after_position: 0, limit: limit, first_page: true)
37
+ end
38
+
39
+ def self.resume(collection, model:, field:, direction:, cursor:, snapshot_param:, limit:)
40
+ raise InvalidExportSnapshotError unless SnapshotRow.available?
41
+ raise InvalidExportSnapshotError if cursor.nil? || cursor.position.nil?
42
+
43
+ token = authentic_token(snapshot_param, model.name)
44
+ walk(collection, model: model, field: field, direction: direction,
45
+ token: token, after_position: cursor.position, limit: limit, first_page: false)
46
+ end
47
+
48
+ def self.live(collection, model:, field:, direction:, cursor:, limit:)
49
+ records = KeysetPage.records(
50
+ collection, model: model, field: field, direction: direction, cursor: cursor, limit: limit
51
+ )
52
+ Page.new(
53
+ records: records,
54
+ snapshot_token: nil,
55
+ next_cursor: KeysetPage.next_cursor(
56
+ records, field: field, direction: direction, primary_key: model.primary_key, limit: limit
57
+ ),
58
+ first_page: cursor.nil?
59
+ )
60
+ end
61
+
62
+ def self.insert_ids(ids, resource_type)
63
+ token = SecureRandom.urlsafe_base64(32)
64
+ created_at = Time.current
65
+ ids.each_slice(INSERT_SLICE).with_index do |slice, slice_index|
66
+ SnapshotRow.insert_all(slice.each_with_index.map { |record_id, offset|
67
+ {
68
+ token: token, resource_type: resource_type, record_id: record_id,
69
+ position: (slice_index * INSERT_SLICE) + offset + 1, created_at: created_at
70
+ }
71
+ })
72
+ end
73
+ token
74
+ end
75
+
76
+ def self.authentic_token(raw, resource_type)
77
+ anchor = SnapshotRow.where(token: raw).order(:position).first
78
+ raise InvalidExportSnapshotError if anchor.nil?
79
+ raise InvalidExportSnapshotError if SnapshotRow.expired?(anchor.created_at, ttl: BatchedExport.config.snapshot_ttl)
80
+ raise InvalidExportSnapshotError unless anchor.resource_type == resource_type
81
+
82
+ anchor.token
83
+ end
84
+
85
+ def self.walk(collection, model:, field:, direction:, token:, after_position:, limit:, first_page:)
86
+ records, consumed = fill_live(collection, model: model, token: token,
87
+ after_position: after_position, limit: limit)
88
+ if SnapshotRow.remaining_after?(token, consumed) && records.length == limit
89
+ return Page.new(
90
+ records: records, snapshot_token: token, first_page: first_page,
91
+ next_cursor: KeysetPage.next_cursor(
92
+ records, field: field, direction: direction,
93
+ primary_key: model.primary_key, limit: limit, position: consumed
94
+ )
95
+ )
96
+ end
97
+
98
+ SnapshotRow.delete_token!(token)
99
+ Page.new(records: records, snapshot_token: nil, next_cursor: nil, first_page: first_page)
100
+ end
101
+
102
+ def self.fill_live(collection, model:, token:, after_position:, limit:)
103
+ live = []
104
+ consumed = after_position
105
+ position = after_position
106
+ loop do
107
+ window = SnapshotRow.window_after(token, position, limit)
108
+ break if window.empty?
109
+
110
+ found = live_by_id(collection, model.primary_key, window)
111
+ consumed = append_live(window, found, live, limit)
112
+ break if live.length == limit
113
+
114
+ position = window.last.position
115
+ end
116
+ [live, consumed]
117
+ end
118
+
119
+ def self.live_by_id(collection, primary_key, rows)
120
+ collection.where(primary_key => rows.map(&:record_id))
121
+ .index_by { |record| record.public_send(primary_key).to_i }
122
+ end
123
+
124
+ def self.append_live(window, found, live, limit)
125
+ consumed = nil
126
+ window.each do |row|
127
+ consumed = row.position
128
+ record = found[row.record_id.to_i]
129
+ next unless record
130
+
131
+ live << record
132
+ break if live.length == limit
133
+ end
134
+ consumed
135
+ end
136
+ private_class_method :freeze_first, :resume, :live, :insert_ids, :authentic_token,
137
+ :walk, :fill_live, :live_by_id, :append_live
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module ActiveAdmin
6
+ module BatchedExport
7
+ class SnapshotRow < ::ActiveRecord::Base
8
+ self.table_name = "active_admin_batched_export_snapshot_rows"
9
+ self.record_timestamps = false
10
+
11
+ def self.available?
12
+ table_exists?
13
+ end
14
+
15
+ def self.expire_stale!(ttl:)
16
+ # Global sweep on freeze start. Upgrade to a per-token job when volume matters.
17
+ where("created_at < ?", cutoff(ttl)).delete_all
18
+ end
19
+
20
+ def self.expired?(created_at, ttl:)
21
+ created_at < cutoff(ttl)
22
+ end
23
+
24
+ def self.cutoff(ttl)
25
+ ttl.respond_to?(:ago) ? ttl.ago : Time.current - ttl
26
+ end
27
+
28
+ def self.window_after(token, position, limit)
29
+ where(token: token).where("position > ?", position).order(:position).limit(limit).to_a
30
+ end
31
+
32
+ def self.remaining_after?(token, position)
33
+ where(token: token).where("position > ?", position).exists?
34
+ end
35
+
36
+ def self.delete_token!(token)
37
+ where(token: token).delete_all
38
+ end
39
+ end
40
+ end
41
+ end
@@ -26,6 +26,7 @@ module ActiveAdmin
26
26
  actions: "flex flex-wrap gap-3",
27
27
  primary_button: "rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50",
28
28
  secondary_button: "hidden rounded border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:hover:bg-gray-800",
29
+ cancel_button: "rounded border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:hover:bg-gray-800",
29
30
  back_link: "inline-flex items-center text-sm text-blue-600 hover:underline"
30
31
  }.freeze
31
32
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module BatchedExport
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeadmin_batched_export
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -301,14 +301,14 @@ dependencies:
301
301
  requirements:
302
302
  - - "~>"
303
303
  - !ruby/object:Gem::Version
304
- version: '3'
304
+ version: '4'
305
305
  type: :development
306
306
  prerelease: false
307
307
  version_requirements: !ruby/object:Gem::Requirement
308
308
  requirements:
309
309
  - - "~>"
310
310
  - !ruby/object:Gem::Version
311
- version: '3'
311
+ version: '4'
312
312
  description: Replaces long-lived ActiveAdmin index downloads with a batched export
313
313
  workspace, customizable views, and export column macros.
314
314
  email:
@@ -326,6 +326,7 @@ files:
326
326
  - SECURITY.md
327
327
  - activeadmin_batched_export.gemspec
328
328
  - app/assets/controllers/activeadmin_batched_export/batched_export_controller.js
329
+ - app/assets/javascripts/activeadmin_batched_export/chunk_assembly.mjs
329
330
  - app/views/active_admin/batched_export/_actions.html.erb
330
331
  - app/views/active_admin/batched_export/_columns.html.erb
331
332
  - app/views/active_admin/batched_export/_filters.html.erb
@@ -336,13 +337,20 @@ files:
336
337
  - config/locales/activeadmin_batched_export.en.yml
337
338
  - config/polyrun_coverage.yml
338
339
  - lib/activeadmin/batched_export.rb
340
+ - lib/activeadmin/batched_export/chunk_renderer.rb
339
341
  - lib/activeadmin/batched_export/configuration.rb
340
342
  - lib/activeadmin/batched_export/controller_methods.rb
341
343
  - lib/activeadmin/batched_export/engine.rb
344
+ - lib/activeadmin/batched_export/errors.rb
345
+ - lib/activeadmin/batched_export/export_cursor.rb
342
346
  - lib/activeadmin/batched_export/export_macro_catalog.rb
343
347
  - lib/activeadmin/batched_export/export_macro_resolver.rb
344
348
  - lib/activeadmin/batched_export/install.rb
349
+ - lib/activeadmin/batched_export/keyset_page.rb
345
350
  - lib/activeadmin/batched_export/resource_extension.rb
351
+ - lib/activeadmin/batched_export/row_sanitizer.rb
352
+ - lib/activeadmin/batched_export/snapshot_page.rb
353
+ - lib/activeadmin/batched_export/snapshot_row.rb
346
354
  - lib/activeadmin/batched_export/styles.rb
347
355
  - lib/activeadmin/batched_export/version.rb
348
356
  - lib/activeadmin_batched_export.rb
@@ -364,14 +372,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
364
372
  requirements:
365
373
  - - ">="
366
374
  - !ruby/object:Gem::Version
367
- version: 3.2.0
375
+ version: '3.4'
368
376
  required_rubygems_version: !ruby/object:Gem::Requirement
369
377
  requirements:
370
378
  - - ">="
371
379
  - !ruby/object:Gem::Version
372
380
  version: '0'
373
381
  requirements: []
374
- rubygems_version: 4.0.3
382
+ rubygems_version: 4.0.6
375
383
  specification_version: 4
376
384
  summary: Batched CSV, JSON, and XML export workspace for ActiveAdmin 4
377
385
  test_files: []