combinaut_stagehand 1.2.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/Rakefile +20 -0
- data/lib/combinaut_stagehand.rb +2 -0
- data/lib/stagehand/active_record_extensions.rb +110 -0
- data/lib/stagehand/auditor/checklist_visualizer.rb +90 -0
- data/lib/stagehand/auditor.rb +90 -0
- data/lib/stagehand/cache.rb +12 -0
- data/lib/stagehand/configuration.rb +45 -0
- data/lib/stagehand/connection_adapter_extensions.rb +75 -0
- data/lib/stagehand/controller_extensions.rb +35 -0
- data/lib/stagehand/database.rb +197 -0
- data/lib/stagehand/engine.rb +26 -0
- data/lib/stagehand/key.rb +23 -0
- data/lib/stagehand/production/controller.rb +12 -0
- data/lib/stagehand/production.rb +132 -0
- data/lib/stagehand/schema/statements.rb +53 -0
- data/lib/stagehand/schema.rb +145 -0
- data/lib/stagehand/schema_extensions.rb +10 -0
- data/lib/stagehand/staging/checklist.rb +202 -0
- data/lib/stagehand/staging/commit.rb +168 -0
- data/lib/stagehand/staging/commit_entry.rb +180 -0
- data/lib/stagehand/staging/controller.rb +27 -0
- data/lib/stagehand/staging/model.rb +29 -0
- data/lib/stagehand/staging/synchronizer.rb +198 -0
- data/lib/stagehand/staging.rb +11 -0
- data/lib/stagehand/version.rb +3 -0
- data/lib/stagehand.rb +5 -0
- data/lib/tasks/stagehand_tasks.rake +43 -0
- metadata +166 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
module Stagehand
|
|
2
|
+
module Staging
|
|
3
|
+
module Synchronizer
|
|
4
|
+
extend self
|
|
5
|
+
mattr_accessor :schemas_match
|
|
6
|
+
|
|
7
|
+
BATCH_SIZE = 1000
|
|
8
|
+
ENTRY_SYNC_ORDER = [:delete, :update, :insert].freeze
|
|
9
|
+
ENTRY_SYNC_ORDER_SQL = Arel.sql(ActiveRecord::Base.send(:sanitize_sql_for_order, [Arel.sql('FIELD(operation, ?), id ASC'), ENTRY_SYNC_ORDER])).freeze
|
|
10
|
+
|
|
11
|
+
# Immediately sync the changes from the block, preconfirming all changes
|
|
12
|
+
# The block is wrapped in a transaction to prevent changes to records while being synced
|
|
13
|
+
def sync_now!(*args, **opts, &block)
|
|
14
|
+
sync_now(*args, **opts, preconfirmed: true, &block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Immediately attempt to sync the changes from the block if possible
|
|
18
|
+
# The block is wrapped in a transaction to prevent changes to records while being synced
|
|
19
|
+
def sync_now(subject_record = nil, preconfirmed: false, **opts, &block)
|
|
20
|
+
raise SyncBlockRequired unless block_given?
|
|
21
|
+
|
|
22
|
+
Rails.logger.info "Syncing Now (preconfirmed: #{preconfirmed})"
|
|
23
|
+
Database.transaction do
|
|
24
|
+
commit = Commit.capture(subject_record, &block)
|
|
25
|
+
next unless commit # If the commit was empty don't continue
|
|
26
|
+
checklist = Checklist.new(commit.entries)
|
|
27
|
+
sync_checklist(checklist, **opts) if preconfirmed || !checklist.requires_confirmation?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def auto_sync(polling_delay = 5.seconds, **opts)
|
|
32
|
+
loop do
|
|
33
|
+
Rails.logger.info "Autosyncing"
|
|
34
|
+
sync(BATCH_SIZE, **opts)
|
|
35
|
+
sleep(polling_delay) if polling_delay
|
|
36
|
+
rescue Database::NoRetryError => e
|
|
37
|
+
Rails.logger.info "Autosyncing encountered a NoRetryError"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sync(limit = nil, **opts)
|
|
42
|
+
synced_count = 0
|
|
43
|
+
deleted_count = 0
|
|
44
|
+
|
|
45
|
+
Rails.logger.info "Syncing"
|
|
46
|
+
|
|
47
|
+
iterate_autosyncable_entries do |entry|
|
|
48
|
+
sync_entry(entry, :callbacks => :sync, **opts)
|
|
49
|
+
synced_count += 1
|
|
50
|
+
|
|
51
|
+
scope = CommitEntry.matching(entry).not_in_progress
|
|
52
|
+
scope = scope.save_operations unless entry.delete_operation?
|
|
53
|
+
deleted_count += delete_without_range_locks(scope)
|
|
54
|
+
|
|
55
|
+
break if synced_count == limit
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Rails.logger.info "Synced #{synced_count} entries"
|
|
59
|
+
Rails.logger.info "Removed #{deleted_count} stale entries"
|
|
60
|
+
|
|
61
|
+
return synced_count
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sync_all(**opts)
|
|
65
|
+
loop do
|
|
66
|
+
entries = CommitEntry.order(ENTRY_SYNC_ORDER_SQL).limit(BATCH_SIZE).to_a
|
|
67
|
+
break unless entries.present?
|
|
68
|
+
|
|
69
|
+
latest_entries = entries.uniq(&:key)
|
|
70
|
+
latest_entries.each {|entry| sync_entry(entry, :callbacks => :sync, **opts) }
|
|
71
|
+
Rails.logger.info "Synced #{latest_entries.count} entries"
|
|
72
|
+
|
|
73
|
+
deleted_count = delete_without_range_locks(CommitEntry.matching(latest_entries))
|
|
74
|
+
Rails.logger.info "Removed #{deleted_count - latest_entries.count} stale entries"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Copies all the affected records from the staging database to the production database
|
|
79
|
+
def sync_record(record, **opts)
|
|
80
|
+
sync_checklist(Checklist.new(record), **opts)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def sync_checklist(checklist, **opts)
|
|
84
|
+
Database.transaction do
|
|
85
|
+
checklist.syncing_entries.each do |entry|
|
|
86
|
+
if checklist.subject_entries.include?(entry)
|
|
87
|
+
sync_entry(entry, :callbacks => [:sync, :sync_as_subject], **opts)
|
|
88
|
+
else
|
|
89
|
+
sync_entry(entry, :callbacks => [:sync, :sync_as_affected], **opts)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
delete_without_range_locks(checklist.affected_entries)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Lazily iterate through millions of commit entries
|
|
100
|
+
# Returns commit entries in ID descending order
|
|
101
|
+
def iterate_autosyncable_entries(&block)
|
|
102
|
+
current = CommitEntry.maximum(:id).to_i
|
|
103
|
+
|
|
104
|
+
while entries = autosyncable_entries("id <= #{current}").limit(BATCH_SIZE).order(ENTRY_SYNC_ORDER_SQL).to_a.presence do
|
|
105
|
+
with_confirmed_autosyncability(entries.uniq(&:key), &block)
|
|
106
|
+
current = entries.last.try(:id).to_i - 1
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Executes the code in the block if the record referred to by the entry is in fact, autosyncable.
|
|
111
|
+
# This confirmation is used to guard against writes to the record that occur after loading an initial list of
|
|
112
|
+
# entries that are autosyncable, but before the record is actually synced. To prevent this, a lock on the record
|
|
113
|
+
# is acquired and then the record's autosync eligibility is rechecked before calling the block.
|
|
114
|
+
def with_confirmed_autosyncability(entries, &block)
|
|
115
|
+
entries = Array.wrap(entries)
|
|
116
|
+
return unless entries.present?
|
|
117
|
+
|
|
118
|
+
Database.transaction do
|
|
119
|
+
# Lock the records so nothing can update them after we confirm autosyncability
|
|
120
|
+
acquire_record_locks(entries)
|
|
121
|
+
|
|
122
|
+
# Execute the block for each entry we've confirm autosyncability
|
|
123
|
+
confirmed_ids = Set.new(autosyncable_entries.where(:id => entries).pluck(:id))
|
|
124
|
+
|
|
125
|
+
entries.each do |entry|
|
|
126
|
+
block.call(entry) if confirmed_ids.include?(entry.id)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Does not actually acquire a lock, instead it triggers a 'first read' so the transaction will ensure subsequent
|
|
132
|
+
# reads of the locked rows return the same value, even if modified outside of the transaction
|
|
133
|
+
def acquire_record_locks(entries)
|
|
134
|
+
entries.each(&:record)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def autosyncable_entries(scope = nil)
|
|
138
|
+
entries = CommitEntry.content_operations.where(scope)
|
|
139
|
+
if Configuration.ghost_mode?
|
|
140
|
+
entries = entries.not_in_progress
|
|
141
|
+
else
|
|
142
|
+
entries = entries.with_uncontained_keys
|
|
143
|
+
end
|
|
144
|
+
return entries
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def sync_entry(entry, callbacks: false)
|
|
148
|
+
raise SchemaMismatch unless schemas_match?
|
|
149
|
+
|
|
150
|
+
run_sync_callbacks(entry, callbacks) do
|
|
151
|
+
next unless entry.content_operation? # Only sync records from content operations because those are the only rows that have changes
|
|
152
|
+
next if Configuration.single_connection? # Avoid deadlocking if the databases are the same. There is nothing to sync because there is only a single database
|
|
153
|
+
|
|
154
|
+
Rails.logger.info "Synchronizing #{entry.table_name} #{entry.record_id} (#{entry.operation})"
|
|
155
|
+
|
|
156
|
+
if entry.delete_operation?
|
|
157
|
+
Production.delete(entry)
|
|
158
|
+
elsif entry.save_operation?
|
|
159
|
+
Production.save(entry)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
Rails.logger.info "Synchronized #{entry.table_name} #{entry.record_id} (#{entry.operation})"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def schemas_match?
|
|
167
|
+
return schemas_match unless schemas_match.nil?
|
|
168
|
+
self.schemas_match = Database.staging_database_versions == Database.production_database_versions
|
|
169
|
+
return schemas_match
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def run_sync_callbacks(entry, callbacks, &block)
|
|
173
|
+
callbacks = Array.wrap(callbacks.presence).dup
|
|
174
|
+
return block.call unless callbacks.present? && entry.record
|
|
175
|
+
|
|
176
|
+
entry.record.run_callbacks(callbacks.shift) do
|
|
177
|
+
run_sync_callbacks(entry, callbacks, &block)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Deletes records without acquiring range locks which have a higher likelihood of causing a deadlock.
|
|
182
|
+
# See https://dev.mysql.com/doc/refman/5.6/en/innodb-locks-set.html for info on locks set by SQL statements.
|
|
183
|
+
def delete_without_range_locks(commit_entries)
|
|
184
|
+
ids = commit_entries.pluck(:id)
|
|
185
|
+
ids.in_groups_of(1000, false) do |batch|
|
|
186
|
+
CommitEntry.delete(batch)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
return ids.length
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# EXCEPTIONS
|
|
195
|
+
|
|
196
|
+
class SyncBlockRequired < StandardError; end
|
|
197
|
+
class SchemaMismatch < StandardError; end
|
|
198
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
require 'stagehand/staging/commit'
|
|
2
|
+
require 'stagehand/staging/commit_entry'
|
|
3
|
+
require 'stagehand/staging/checklist'
|
|
4
|
+
require 'stagehand/staging/controller'
|
|
5
|
+
require 'stagehand/staging/model'
|
|
6
|
+
require 'stagehand/staging/synchronizer'
|
|
7
|
+
|
|
8
|
+
module Stagehand
|
|
9
|
+
module Staging
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/stagehand.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
namespace :stagehand do
|
|
2
|
+
desc "Polls the commit entries table for changes to sync to production"
|
|
3
|
+
task :auto_sync, [:delay] => :environment do |t, args|
|
|
4
|
+
delay = args[:delay].present? ? args[:delay].to_i : 5.seconds
|
|
5
|
+
Stagehand::Staging::Synchronizer.auto_sync(delay)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
desc "Syncs records that don't need confirmation to production"
|
|
9
|
+
task :sync, [:limit] => :environment do |t, args|
|
|
10
|
+
limit = args[:limit].present? ? args[:limit].to_i : nil
|
|
11
|
+
Stagehand::Staging::Synchronizer.sync(limit)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "Syncs all records to production, including those that require confirmation"
|
|
15
|
+
task :sync_all => :environment do
|
|
16
|
+
Stagehand::Staging::Synchronizer.sync_all
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Enhance the regular tasks to run on both staging and production databases
|
|
20
|
+
def rake_both_databases(task, stagehand_task = task.gsub(':','_'))
|
|
21
|
+
task(stagehand_task => :environment) do
|
|
22
|
+
Stagehand::Database.each do |connection_name|
|
|
23
|
+
Stagehand::Connection.with_production_writes do
|
|
24
|
+
puts "#{connection_name}"
|
|
25
|
+
Rake::Task[task].reenable
|
|
26
|
+
Rake::Task[task].invoke
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
Rake::Task[task].clear
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Enhance the original task to run the stagehand_task as a prerequisite
|
|
33
|
+
Rake::Task[task].enhance(["stagehand:#{stagehand_task}"]) unless Stagehand::Configuration.single_connection?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
rake_both_databases('db:create')
|
|
37
|
+
rake_both_databases('db:migrate')
|
|
38
|
+
rake_both_databases('db:migrate:up')
|
|
39
|
+
rake_both_databases('db:migrate:down')
|
|
40
|
+
rake_both_databases('db:rollback')
|
|
41
|
+
rake_both_databases('db:test:load_structure')
|
|
42
|
+
rake_both_databases('db:test:load_schema')
|
|
43
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: combinaut_stagehand
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.2.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nicholas Jakobsen
|
|
8
|
+
- Ryan Wallace
|
|
9
|
+
autorequire:
|
|
10
|
+
bindir: bin
|
|
11
|
+
cert_chain: []
|
|
12
|
+
date: 2025-11-07 00:00:00.000000000 Z
|
|
13
|
+
dependencies:
|
|
14
|
+
- !ruby/object:Gem::Dependency
|
|
15
|
+
name: rails
|
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
|
17
|
+
requirements:
|
|
18
|
+
- - ">="
|
|
19
|
+
- !ruby/object:Gem::Version
|
|
20
|
+
version: '4.2'
|
|
21
|
+
- - "<"
|
|
22
|
+
- !ruby/object:Gem::Version
|
|
23
|
+
version: '6'
|
|
24
|
+
type: :runtime
|
|
25
|
+
prerelease: false
|
|
26
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
27
|
+
requirements:
|
|
28
|
+
- - ">="
|
|
29
|
+
- !ruby/object:Gem::Version
|
|
30
|
+
version: '4.2'
|
|
31
|
+
- - "<"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '6'
|
|
34
|
+
- !ruby/object:Gem::Dependency
|
|
35
|
+
name: mysql2
|
|
36
|
+
requirement: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
41
|
+
type: :runtime
|
|
42
|
+
prerelease: false
|
|
43
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
- !ruby/object:Gem::Dependency
|
|
49
|
+
name: ruby-graphviz
|
|
50
|
+
requirement: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0'
|
|
55
|
+
type: :runtime
|
|
56
|
+
prerelease: false
|
|
57
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0'
|
|
62
|
+
- !ruby/object:Gem::Dependency
|
|
63
|
+
name: combustion
|
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 1.3.0
|
|
69
|
+
type: :development
|
|
70
|
+
prerelease: false
|
|
71
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: 1.3.0
|
|
76
|
+
- !ruby/object:Gem::Dependency
|
|
77
|
+
name: rspec-rails
|
|
78
|
+
requirement: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.7'
|
|
83
|
+
type: :development
|
|
84
|
+
prerelease: false
|
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.7'
|
|
90
|
+
- !ruby/object:Gem::Dependency
|
|
91
|
+
name: database_cleaner
|
|
92
|
+
requirement: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
type: :development
|
|
98
|
+
prerelease: false
|
|
99
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
description: Simplify the management of a sandbox database that can sync content to
|
|
105
|
+
a production database. Content changes can be bundled to allow partial syncs of
|
|
106
|
+
the database.
|
|
107
|
+
email:
|
|
108
|
+
- nicholas@combinaut.com
|
|
109
|
+
- ryan@combinaut.com
|
|
110
|
+
executables: []
|
|
111
|
+
extensions: []
|
|
112
|
+
extra_rdoc_files: []
|
|
113
|
+
files:
|
|
114
|
+
- MIT-LICENSE
|
|
115
|
+
- Rakefile
|
|
116
|
+
- lib/combinaut_stagehand.rb
|
|
117
|
+
- lib/stagehand.rb
|
|
118
|
+
- lib/stagehand/active_record_extensions.rb
|
|
119
|
+
- lib/stagehand/auditor.rb
|
|
120
|
+
- lib/stagehand/auditor/checklist_visualizer.rb
|
|
121
|
+
- lib/stagehand/cache.rb
|
|
122
|
+
- lib/stagehand/configuration.rb
|
|
123
|
+
- lib/stagehand/connection_adapter_extensions.rb
|
|
124
|
+
- lib/stagehand/controller_extensions.rb
|
|
125
|
+
- lib/stagehand/database.rb
|
|
126
|
+
- lib/stagehand/engine.rb
|
|
127
|
+
- lib/stagehand/key.rb
|
|
128
|
+
- lib/stagehand/production.rb
|
|
129
|
+
- lib/stagehand/production/controller.rb
|
|
130
|
+
- lib/stagehand/schema.rb
|
|
131
|
+
- lib/stagehand/schema/statements.rb
|
|
132
|
+
- lib/stagehand/schema_extensions.rb
|
|
133
|
+
- lib/stagehand/staging.rb
|
|
134
|
+
- lib/stagehand/staging/checklist.rb
|
|
135
|
+
- lib/stagehand/staging/commit.rb
|
|
136
|
+
- lib/stagehand/staging/commit_entry.rb
|
|
137
|
+
- lib/stagehand/staging/controller.rb
|
|
138
|
+
- lib/stagehand/staging/model.rb
|
|
139
|
+
- lib/stagehand/staging/synchronizer.rb
|
|
140
|
+
- lib/stagehand/version.rb
|
|
141
|
+
- lib/tasks/stagehand_tasks.rake
|
|
142
|
+
homepage: https://github.com/combinaut/stagehand
|
|
143
|
+
licenses:
|
|
144
|
+
- MIT
|
|
145
|
+
metadata: {}
|
|
146
|
+
post_install_message:
|
|
147
|
+
rdoc_options: []
|
|
148
|
+
require_paths:
|
|
149
|
+
- lib
|
|
150
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
151
|
+
requirements:
|
|
152
|
+
- - ">="
|
|
153
|
+
- !ruby/object:Gem::Version
|
|
154
|
+
version: '0'
|
|
155
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
156
|
+
requirements:
|
|
157
|
+
- - ">="
|
|
158
|
+
- !ruby/object:Gem::Version
|
|
159
|
+
version: '0'
|
|
160
|
+
requirements: []
|
|
161
|
+
rubygems_version: 3.1.6
|
|
162
|
+
signing_key:
|
|
163
|
+
specification_version: 4
|
|
164
|
+
summary: Simplify the management of a sandbox database that can sync content to a
|
|
165
|
+
production database
|
|
166
|
+
test_files: []
|