matthewtodd-taps 0.2.19
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.
- data/LICENSE +20 -0
- data/README.rdoc +41 -0
- data/Rakefile +61 -0
- data/VERSION.yml +4 -0
- data/bin/schema +42 -0
- data/bin/schema.cmd +6 -0
- data/bin/taps +5 -0
- data/lib/taps/adapter_hacks.rb +21 -0
- data/lib/taps/adapter_hacks/invalid_binary_limit.rb +13 -0
- data/lib/taps/adapter_hacks/invalid_text_limit.rb +13 -0
- data/lib/taps/adapter_hacks/mysql_invalid_primary_key.rb +17 -0
- data/lib/taps/adapter_hacks/non_rails_schema_dump.rb +15 -0
- data/lib/taps/cli.rb +62 -0
- data/lib/taps/client_session.rb +306 -0
- data/lib/taps/config.rb +33 -0
- data/lib/taps/db_session.rb +62 -0
- data/lib/taps/progress_bar.rb +236 -0
- data/lib/taps/schema.rb +94 -0
- data/lib/taps/server.rb +138 -0
- data/lib/taps/utils.rb +115 -0
- data/spec/base.rb +21 -0
- data/spec/client_session_spec.rb +88 -0
- data/spec/schema_spec.rb +45 -0
- data/spec/server_spec.rb +33 -0
- data/spec/utils_spec.rb +61 -0
- metadata +151 -0
data/lib/taps/config.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
require 'sqlite3'
|
3
|
+
|
4
|
+
module Taps
|
5
|
+
def self.version_yml
|
6
|
+
@@version_yml ||= YAML.load(File.read(File.dirname(__FILE__) + '/../../VERSION.yml'))
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.version
|
10
|
+
"#{version_yml[:major]}.#{version_yml[:minor]}.#{version_yml[:patch]}"
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.compatible_version
|
14
|
+
"#{version_yml[:major]}.#{version_yml[:minor]}"
|
15
|
+
end
|
16
|
+
|
17
|
+
class Config
|
18
|
+
class << self
|
19
|
+
attr_accessor :taps_database_url
|
20
|
+
attr_accessor :login, :password, :database_url, :remote_url
|
21
|
+
attr_accessor :chunksize
|
22
|
+
|
23
|
+
def verify_database_url
|
24
|
+
db = Sequel.connect(self.database_url)
|
25
|
+
db.tables
|
26
|
+
db.disconnect
|
27
|
+
rescue Object => e
|
28
|
+
puts "Failed to connect to database:\n #{e.class} -> #{e}"
|
29
|
+
exit 1
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'thread'
|
2
|
+
|
3
|
+
Sequel::Model.db = Sequel.connect(Taps::Config.taps_database_url)
|
4
|
+
|
5
|
+
class DbSession < Sequel::Model
|
6
|
+
plugin :schema
|
7
|
+
set_schema do
|
8
|
+
primary_key :id
|
9
|
+
text :key
|
10
|
+
text :database_url
|
11
|
+
timestamp :started_at
|
12
|
+
timestamp :last_access
|
13
|
+
end
|
14
|
+
|
15
|
+
@@connections = {}
|
16
|
+
@@mutex = Mutex.new
|
17
|
+
|
18
|
+
def connection
|
19
|
+
@@mutex.synchronize {
|
20
|
+
conn =
|
21
|
+
if @@connections.key?(key)
|
22
|
+
@@connections[key].first
|
23
|
+
else
|
24
|
+
Sequel.connect(database_url)
|
25
|
+
end
|
26
|
+
@@connections[key] = [conn, Time.now]
|
27
|
+
return conn
|
28
|
+
}
|
29
|
+
end
|
30
|
+
|
31
|
+
def disconnect
|
32
|
+
@@mutex.synchronize {
|
33
|
+
if @@connections.key?(key)
|
34
|
+
conn, time = @@connections.delete(key)
|
35
|
+
conn.disconnect
|
36
|
+
end
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
# Removes connections that have not been accessed within the
|
41
|
+
# past thirty seconds.
|
42
|
+
def self.cleanup
|
43
|
+
@@mutex.synchronize {
|
44
|
+
now = Time.now
|
45
|
+
@@connections.each do |key, (conn, time)|
|
46
|
+
if now - time > 30
|
47
|
+
@@connections.delete(key)
|
48
|
+
conn.disconnect
|
49
|
+
end
|
50
|
+
end
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
Thread.new {
|
55
|
+
while true
|
56
|
+
sleep 30
|
57
|
+
cleanup
|
58
|
+
end
|
59
|
+
}.run
|
60
|
+
end
|
61
|
+
|
62
|
+
DbSession.create_table! unless DbSession.table_exists?
|
@@ -0,0 +1,236 @@
|
|
1
|
+
#
|
2
|
+
# Ruby/ProgressBar - a text progress bar library
|
3
|
+
#
|
4
|
+
# Copyright (C) 2001-2005 Satoru Takabayashi <satoru@namazu.org>
|
5
|
+
# All rights reserved.
|
6
|
+
# This is free software with ABSOLUTELY NO WARRANTY.
|
7
|
+
#
|
8
|
+
# You can redistribute it and/or modify it under the terms
|
9
|
+
# of Ruby's license.
|
10
|
+
#
|
11
|
+
|
12
|
+
class ProgressBar
|
13
|
+
VERSION = "0.9"
|
14
|
+
|
15
|
+
def initialize (title, total, out = STDERR)
|
16
|
+
@title = title
|
17
|
+
@total = total
|
18
|
+
@out = out
|
19
|
+
@terminal_width = 80
|
20
|
+
@bar_mark = "="
|
21
|
+
@current = 0
|
22
|
+
@previous = 0
|
23
|
+
@finished_p = false
|
24
|
+
@start_time = Time.now
|
25
|
+
@previous_time = @start_time
|
26
|
+
@title_width = 14
|
27
|
+
@format = "%-#{@title_width}s %3d%% %s %s"
|
28
|
+
@format_arguments = [:title, :percentage, :bar, :stat]
|
29
|
+
clear
|
30
|
+
show
|
31
|
+
end
|
32
|
+
attr_reader :title
|
33
|
+
attr_reader :current
|
34
|
+
attr_reader :total
|
35
|
+
attr_accessor :start_time
|
36
|
+
|
37
|
+
private
|
38
|
+
def fmt_bar
|
39
|
+
bar_width = do_percentage * @terminal_width / 100
|
40
|
+
sprintf("|%s%s|",
|
41
|
+
@bar_mark * bar_width,
|
42
|
+
" " * (@terminal_width - bar_width))
|
43
|
+
end
|
44
|
+
|
45
|
+
def fmt_percentage
|
46
|
+
do_percentage
|
47
|
+
end
|
48
|
+
|
49
|
+
def fmt_stat
|
50
|
+
if @finished_p then elapsed else eta end
|
51
|
+
end
|
52
|
+
|
53
|
+
def fmt_stat_for_file_transfer
|
54
|
+
if @finished_p then
|
55
|
+
sprintf("%s %s %s", bytes, transfer_rate, elapsed)
|
56
|
+
else
|
57
|
+
sprintf("%s %s %s", bytes, transfer_rate, eta)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def fmt_title
|
62
|
+
@title[0,(@title_width - 1)] + ":"
|
63
|
+
end
|
64
|
+
|
65
|
+
def convert_bytes (bytes)
|
66
|
+
if bytes < 1024
|
67
|
+
sprintf("%6dB", bytes)
|
68
|
+
elsif bytes < 1024 * 1000 # 1000kb
|
69
|
+
sprintf("%5.1fKB", bytes.to_f / 1024)
|
70
|
+
elsif bytes < 1024 * 1024 * 1000 # 1000mb
|
71
|
+
sprintf("%5.1fMB", bytes.to_f / 1024 / 1024)
|
72
|
+
else
|
73
|
+
sprintf("%5.1fGB", bytes.to_f / 1024 / 1024 / 1024)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def transfer_rate
|
78
|
+
bytes_per_second = @current.to_f / (Time.now - @start_time)
|
79
|
+
sprintf("%s/s", convert_bytes(bytes_per_second))
|
80
|
+
end
|
81
|
+
|
82
|
+
def bytes
|
83
|
+
convert_bytes(@current)
|
84
|
+
end
|
85
|
+
|
86
|
+
def format_time (t)
|
87
|
+
t = t.to_i
|
88
|
+
sec = t % 60
|
89
|
+
min = (t / 60) % 60
|
90
|
+
hour = t / 3600
|
91
|
+
sprintf("%02d:%02d:%02d", hour, min, sec);
|
92
|
+
end
|
93
|
+
|
94
|
+
# ETA stands for Estimated Time of Arrival.
|
95
|
+
def eta
|
96
|
+
if @current == 0
|
97
|
+
"ETA: --:--:--"
|
98
|
+
else
|
99
|
+
elapsed = Time.now - @start_time
|
100
|
+
eta = elapsed * @total / @current - elapsed;
|
101
|
+
sprintf("ETA: %s", format_time(eta))
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def elapsed
|
106
|
+
elapsed = Time.now - @start_time
|
107
|
+
sprintf("Time: %s", format_time(elapsed))
|
108
|
+
end
|
109
|
+
|
110
|
+
def eol
|
111
|
+
if @finished_p then "\n" else "\r" end
|
112
|
+
end
|
113
|
+
|
114
|
+
def do_percentage
|
115
|
+
if @total.zero?
|
116
|
+
100
|
117
|
+
else
|
118
|
+
@current * 100 / @total
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def get_width
|
123
|
+
# FIXME: I don't know how portable it is.
|
124
|
+
default_width = 80
|
125
|
+
begin
|
126
|
+
tiocgwinsz = 0x5413
|
127
|
+
data = [0, 0, 0, 0].pack("SSSS")
|
128
|
+
if @out.ioctl(tiocgwinsz, data) >= 0 then
|
129
|
+
rows, cols, xpixels, ypixels = data.unpack("SSSS")
|
130
|
+
if cols > 0 then cols else default_width end
|
131
|
+
else
|
132
|
+
default_width
|
133
|
+
end
|
134
|
+
rescue Exception
|
135
|
+
default_width
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def show
|
140
|
+
arguments = @format_arguments.map {|method|
|
141
|
+
method = sprintf("fmt_%s", method)
|
142
|
+
send(method)
|
143
|
+
}
|
144
|
+
line = sprintf(@format, *arguments)
|
145
|
+
|
146
|
+
width = get_width
|
147
|
+
if line.length == width - 1
|
148
|
+
@out.print(line + eol)
|
149
|
+
@out.flush
|
150
|
+
elsif line.length >= width
|
151
|
+
@terminal_width = [@terminal_width - (line.length - width + 1), 0].max
|
152
|
+
if @terminal_width == 0 then @out.print(line + eol) else show end
|
153
|
+
else # line.length < width - 1
|
154
|
+
@terminal_width += width - line.length + 1
|
155
|
+
show
|
156
|
+
end
|
157
|
+
@previous_time = Time.now
|
158
|
+
end
|
159
|
+
|
160
|
+
def show_if_needed
|
161
|
+
if @total.zero?
|
162
|
+
cur_percentage = 100
|
163
|
+
prev_percentage = 0
|
164
|
+
else
|
165
|
+
cur_percentage = (@current * 100 / @total).to_i
|
166
|
+
prev_percentage = (@previous * 100 / @total).to_i
|
167
|
+
end
|
168
|
+
|
169
|
+
# Use "!=" instead of ">" to support negative changes
|
170
|
+
if cur_percentage != prev_percentage ||
|
171
|
+
Time.now - @previous_time >= 1 || @finished_p
|
172
|
+
show
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
public
|
177
|
+
def clear
|
178
|
+
@out.print "\r"
|
179
|
+
@out.print(" " * (get_width - 1))
|
180
|
+
@out.print "\r"
|
181
|
+
end
|
182
|
+
|
183
|
+
def finish
|
184
|
+
@current = @total
|
185
|
+
@finished_p = true
|
186
|
+
show
|
187
|
+
end
|
188
|
+
|
189
|
+
def finished?
|
190
|
+
@finished_p
|
191
|
+
end
|
192
|
+
|
193
|
+
def file_transfer_mode
|
194
|
+
@format_arguments = [:title, :percentage, :bar, :stat_for_file_transfer]
|
195
|
+
end
|
196
|
+
|
197
|
+
def format= (format)
|
198
|
+
@format = format
|
199
|
+
end
|
200
|
+
|
201
|
+
def format_arguments= (arguments)
|
202
|
+
@format_arguments = arguments
|
203
|
+
end
|
204
|
+
|
205
|
+
def halt
|
206
|
+
@finished_p = true
|
207
|
+
show
|
208
|
+
end
|
209
|
+
|
210
|
+
def inc (step = 1)
|
211
|
+
@current += step
|
212
|
+
@current = @total if @current > @total
|
213
|
+
show_if_needed
|
214
|
+
@previous = @current
|
215
|
+
end
|
216
|
+
|
217
|
+
def set (count)
|
218
|
+
if count < 0 || count > @total
|
219
|
+
raise "invalid count: #{count} (total: #{@total})"
|
220
|
+
end
|
221
|
+
@current = count
|
222
|
+
show_if_needed
|
223
|
+
@previous = @current
|
224
|
+
end
|
225
|
+
|
226
|
+
def inspect
|
227
|
+
"#<ProgressBar:#{@current}/#{@total}>"
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
class ReversedProgressBar < ProgressBar
|
232
|
+
def do_percentage
|
233
|
+
100 - super
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
data/lib/taps/schema.rb
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'active_support'
|
3
|
+
require 'stringio'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
require 'taps/adapter_hacks'
|
7
|
+
|
8
|
+
module Taps
|
9
|
+
module Schema
|
10
|
+
extend self
|
11
|
+
|
12
|
+
def create_config(url)
|
13
|
+
uri = URI.parse(url)
|
14
|
+
adapter = uri.scheme
|
15
|
+
adapter = 'postgresql' if adapter == 'postgres'
|
16
|
+
adapter = 'sqlite3' if adapter == 'sqlite'
|
17
|
+
config = {
|
18
|
+
'adapter' => adapter,
|
19
|
+
'database' => uri.path.blank? ? uri.host : uri.path.split('/')[1],
|
20
|
+
'username' => uri.user,
|
21
|
+
'password' => uri.password,
|
22
|
+
'host' => uri.host,
|
23
|
+
}
|
24
|
+
config = sqlite_config(url) if config['adapter'] == 'sqlite3'
|
25
|
+
config
|
26
|
+
end
|
27
|
+
|
28
|
+
def sqlite_config(url)
|
29
|
+
m = %r{(sqlite3?)://(.+)}.match(url)
|
30
|
+
database = m[2]
|
31
|
+
database, q = database.split('?')
|
32
|
+
{ 'adapter' => 'sqlite3', 'database' => database }
|
33
|
+
end
|
34
|
+
|
35
|
+
def connection(database_url)
|
36
|
+
config = create_config(database_url)
|
37
|
+
c = ActiveRecord::Base.establish_connection(config)
|
38
|
+
Taps::AdapterHacks.load(config['adapter'])
|
39
|
+
c
|
40
|
+
end
|
41
|
+
|
42
|
+
def dump(database_url)
|
43
|
+
connection(database_url)
|
44
|
+
|
45
|
+
stream = StringIO.new
|
46
|
+
ActiveRecord::SchemaDumper.ignore_tables = []
|
47
|
+
ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream)
|
48
|
+
stream.string
|
49
|
+
end
|
50
|
+
|
51
|
+
def dump_without_indexes(database_url)
|
52
|
+
schema = dump(database_url)
|
53
|
+
schema.split("\n").collect do |line|
|
54
|
+
if line =~ /^\s+add_index/
|
55
|
+
line = "##{line}"
|
56
|
+
end
|
57
|
+
line
|
58
|
+
end.join("\n")
|
59
|
+
end
|
60
|
+
|
61
|
+
def indexes(database_url)
|
62
|
+
schema = dump(database_url)
|
63
|
+
schema.split("\n").collect do |line|
|
64
|
+
line if line =~ /^\s+add_index/
|
65
|
+
end.uniq.join("\n")
|
66
|
+
end
|
67
|
+
|
68
|
+
def load(database_url, schema)
|
69
|
+
connection(database_url)
|
70
|
+
eval(schema)
|
71
|
+
end
|
72
|
+
|
73
|
+
def load_indexes(database_url, indexes)
|
74
|
+
connection(database_url)
|
75
|
+
|
76
|
+
schema =<<EORUBY
|
77
|
+
ActiveRecord::Schema.define do
|
78
|
+
#{indexes}
|
79
|
+
end
|
80
|
+
EORUBY
|
81
|
+
eval(schema)
|
82
|
+
end
|
83
|
+
|
84
|
+
def reset_db_sequences(database_url)
|
85
|
+
connection(database_url)
|
86
|
+
|
87
|
+
if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!)
|
88
|
+
ActiveRecord::Base.connection.tables.each do |table|
|
89
|
+
ActiveRecord::Base.connection.reset_pk_sequence!(table)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
data/lib/taps/server.rb
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
require 'taps/config'
|
3
|
+
require 'taps/utils'
|
4
|
+
require 'taps/db_session'
|
5
|
+
require 'taps/schema'
|
6
|
+
|
7
|
+
module Taps
|
8
|
+
class Server < Sinatra::Default
|
9
|
+
use Rack::Auth::Basic do |login, password|
|
10
|
+
login == Taps::Config.login && password == Taps::Config.password
|
11
|
+
end
|
12
|
+
|
13
|
+
error do
|
14
|
+
e = request.env['sinatra.error']
|
15
|
+
"Taps Server Error: #{e}"
|
16
|
+
end
|
17
|
+
|
18
|
+
before do
|
19
|
+
major, minor, patch = request.env['HTTP_TAPS_VERSION'].split('.') rescue []
|
20
|
+
unless "#{major}.#{minor}" == Taps.compatible_version
|
21
|
+
halt 417, "Taps v#{Taps.compatible_version}.x is required for this server"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
get '/' do
|
26
|
+
"hello"
|
27
|
+
end
|
28
|
+
|
29
|
+
post '/sessions' do
|
30
|
+
key = rand(9999999999).to_s
|
31
|
+
database_url = Taps::Config.database_url || request.body.string
|
32
|
+
|
33
|
+
DbSession.create(:key => key, :database_url => database_url, :started_at => Time.now, :last_access => Time.now)
|
34
|
+
|
35
|
+
"/sessions/#{key}"
|
36
|
+
end
|
37
|
+
|
38
|
+
post '/sessions/:key/tables/:table' do
|
39
|
+
session = DbSession.filter(:key => params[:key]).first
|
40
|
+
halt 404 unless session
|
41
|
+
|
42
|
+
gzip_data = request.body.read
|
43
|
+
halt 412 unless Taps::Utils.valid_data?(gzip_data, request.env['HTTP_TAPS_CHECKSUM'])
|
44
|
+
|
45
|
+
rows = Marshal.load(Taps::Utils.gunzip(gzip_data))
|
46
|
+
|
47
|
+
db = session.connection
|
48
|
+
table = db[params[:table].to_sym]
|
49
|
+
table.import(rows[:header], rows[:data])
|
50
|
+
|
51
|
+
"#{rows[:data].size}"
|
52
|
+
end
|
53
|
+
|
54
|
+
post '/sessions/:key/reset_sequences' do
|
55
|
+
session = DbSession.filter(:key => params[:key]).first
|
56
|
+
halt 404 unless session
|
57
|
+
|
58
|
+
Taps::Schema.reset_db_sequences(session.database_url)
|
59
|
+
end
|
60
|
+
|
61
|
+
post '/sessions/:key/schema' do
|
62
|
+
session = DbSession.filter(:key => params[:key]).first
|
63
|
+
halt 404 unless session
|
64
|
+
|
65
|
+
schema_data = request.body.read
|
66
|
+
Taps::Schema.load(session.database_url, schema_data)
|
67
|
+
end
|
68
|
+
|
69
|
+
post '/sessions/:key/indexes' do
|
70
|
+
session = DbSession.filter(:key => params[:key]).first
|
71
|
+
halt 404 unless session
|
72
|
+
|
73
|
+
index_data = request.body.read
|
74
|
+
Taps::Schema.load_indexes(session.database_url, index_data)
|
75
|
+
end
|
76
|
+
|
77
|
+
get '/sessions/:key/schema' do
|
78
|
+
session = DbSession.filter(:key => params[:key]).first
|
79
|
+
halt 404 unless session
|
80
|
+
|
81
|
+
Taps::Schema.dump_without_indexes(session.database_url)
|
82
|
+
end
|
83
|
+
|
84
|
+
get '/sessions/:key/indexes' do
|
85
|
+
session = DbSession.filter(:key => params[:key]).first
|
86
|
+
halt 404 unless session
|
87
|
+
|
88
|
+
Taps::Schema.indexes(session.database_url)
|
89
|
+
end
|
90
|
+
|
91
|
+
get '/sessions/:key/tables' do
|
92
|
+
session = DbSession.filter(:key => params[:key]).first
|
93
|
+
halt 404 unless session
|
94
|
+
|
95
|
+
db = session.connection
|
96
|
+
tables = db.tables
|
97
|
+
|
98
|
+
tables_with_counts = tables.inject({}) do |accum, table|
|
99
|
+
accum[table] = db[table].count
|
100
|
+
accum
|
101
|
+
end
|
102
|
+
|
103
|
+
Marshal.dump(tables_with_counts)
|
104
|
+
end
|
105
|
+
|
106
|
+
get '/sessions/:key/tables/:table/:chunk' do
|
107
|
+
session = DbSession.filter(:key => params[:key]).first
|
108
|
+
halt 404 unless session
|
109
|
+
|
110
|
+
chunk = params[:chunk].to_i
|
111
|
+
chunk = 500 if chunk < 1
|
112
|
+
|
113
|
+
offset = params[:offset].to_i
|
114
|
+
offset = 0 if offset < 0
|
115
|
+
|
116
|
+
db = session.connection
|
117
|
+
table = db[params[:table].to_sym]
|
118
|
+
order = Taps::Utils.order_by(db, params[:table].to_sym)
|
119
|
+
string_columns = Taps::Utils.incorrect_blobs(db, params[:table].to_sym)
|
120
|
+
raw_data = Marshal.dump(Taps::Utils.format_data(table.order(*order).limit(chunk, offset).all, string_columns))
|
121
|
+
gzip_data = Taps::Utils.gzip(raw_data)
|
122
|
+
response['Taps-Checksum'] = Taps::Utils.checksum(gzip_data).to_s
|
123
|
+
response['Content-Type'] = "application/octet-stream"
|
124
|
+
gzip_data
|
125
|
+
end
|
126
|
+
|
127
|
+
delete '/sessions/:key' do
|
128
|
+
session = DbSession.filter(:key => params[:key]).first
|
129
|
+
halt 404 unless session
|
130
|
+
|
131
|
+
session.disconnect
|
132
|
+
session.destroy
|
133
|
+
|
134
|
+
"ok"
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|
138
|
+
end
|