litestream 0.14.0-x86_64-linux → 0.16.0-x86_64-linux

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: f5472149be2f48a05e36c2e2f1c3feb212355100f4e3085f90ed821559d49200
4
- data.tar.gz: 7d745ffdd094a4148b7660f7ec32c531c125279ecb8d78ceef72bdea149f5394
3
+ metadata.gz: 1eea2243f44a53621bf8fa287f9b27e8471da73646a4f5d2db4a63402f42c30d
4
+ data.tar.gz: c759e8b2f48804096e02191e2150de4f685f066d861c5f3aefb07310eb275805
5
5
  SHA512:
6
- metadata.gz: a5ef0cfce7f7fd68d235f873882f8651c9141bb5fabf7b9a4d2b8f5144de4f0658e741f2a3c0e7f09ef381b4016f897673c5b2d80064dad58c6d4778647f801c
7
- data.tar.gz: a959c042fd3c62118cad6976ce554c014ecf6259ca62e1949c398f4cd5697d6ebe0aaa4121f7fd108bcfbdfee8facb9a0cd6391f4024e982116f9a5ed52adaf2
6
+ metadata.gz: 94f95bf5a0239fd400b4ae031c99349db0c1297910fab2fa3a2883e05568ac9e0cfba5d5085812205d0bf4003100df0a478c36e011a67fec20571e11dde805e5
7
+ data.tar.gz: 82ad7f444fce743f8212f42a2c1d5196b44af2b845301441da014d44035f3ea8504a49c99cf8dab3edc55ba1779b11f7257ff584e184c2529c2829326e65e8f5
data/README.md CHANGED
@@ -85,16 +85,22 @@ The gem streamlines the configuration process by providing a default configurati
85
85
  The default configuration file looks like this if you only have one SQLite database:
86
86
 
87
87
  ```yaml
88
+ snapshot:
89
+ interval: 24h
90
+ retention: 24h
91
+
88
92
  dbs:
89
93
  - path: storage/production.sqlite3
90
- replicas:
91
- - type: s3
92
- path: storage/production.sqlite3
93
- bucket: $LITESTREAM_REPLICA_BUCKET
94
- access-key-id: $LITESTREAM_ACCESS_KEY_ID
95
- secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
94
+ replica:
95
+ type: s3
96
+ path: storage/production.sqlite3
97
+ bucket: $LITESTREAM_REPLICA_BUCKET
98
+ access-key-id: $LITESTREAM_ACCESS_KEY_ID
99
+ secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
96
100
  ```
97
101
 
102
+ In Litestream 0.5, snapshot interval and retention are global settings rather than per-replica settings.
103
+
98
104
  This is the default for Amazon S3. The full range of possible replica types (e.g. other S3-compatible object storage servers) are covered in Litestream's [replica guides](https://litestream.io/guides/#replica-guides).
99
105
 
100
106
  The gem also provides a default initializer file at `config/initializers/litestream.rb` that allows you to configure various variables referenced in the configuration file in Ruby. By providing a Ruby interface to these environment variables, you can use your preferred method of storing secrets. For example, the default generated file uses Rails' encrypted credentials to store your secrets.
@@ -126,6 +132,28 @@ end
126
132
 
127
133
  However, if you need manual control over the Litestream configuration, you can edit the `config/litestream.yml` file. The full range of possible configurations are covered in Litestream's [configuration reference](https://litestream.io/reference/config/).
128
134
 
135
+ #### Logging
136
+
137
+ Litestream writes its logs to stdout, where they mix with your application's. Its `logging:` block can make them easy to separate, for example as JSON on stderr:
138
+
139
+ ```yaml
140
+ logging:
141
+ level: info
142
+ type: json
143
+ stderr: true
144
+ ```
145
+
146
+ #### Replicas that authenticate with a credentials file
147
+
148
+ Some replica types read credentials from a file. Google Cloud Storage, for example, reads the path in `GOOGLE_APPLICATION_CREDENTIALS`. A file on your machine is not in the container that Kamal deploys, so pass its contents as a secret and write it out before the app starts, for example in `bin/docker-entrypoint`:
149
+
150
+ ```shell
151
+ # .kamal/secrets: GCS_CREDENTIALS_JSON=$(cat config/gcs.json)
152
+ # config/deploy.yml: env: { secret: [GCS_CREDENTIALS_JSON] }
153
+ printf '%s' "$GCS_CREDENTIALS_JSON" > /rails/tmp/gcs.json
154
+ export GOOGLE_APPLICATION_CREDENTIALS=/rails/tmp/gcs.json
155
+ ```
156
+
129
157
  ### Replication
130
158
 
131
159
  In order to stream changes to your configured replicas, you need to start the Litestream replication process.
@@ -179,7 +207,7 @@ You can restore any replicated database at any point using the gem's provided `l
179
207
  > [!NOTE]
180
208
  > During the restoration process, you need to prevent any interaction with ActiveRecord/SQLite, such as from a running `rails server` or `rails console` instance. If there is any interaction, Rails might regenerate the production database and prevent restoration via litestream. If this happens, you might get a "cannot restore, output path already exists" error.
181
209
 
182
- 1. Rename the production (`production.sqlite3`, `production.sqlite3-shm`, and `production.sqlite3-wal`) databases (**recommended**) or alternatively delete. To delete the production databases locally, you can run the following at your own risk:
210
+ 1. Rename the production database and its SQLite sidecar files (**recommended**) or alternatively delete them. To delete the production databases locally, you can run the following at your own risk:
183
211
  ```shell
184
212
  # DANGEROUS OPERATION, consider renaming database files instead
185
213
  bin/rails db:drop DISABLE_DATABASE_ENVIRONMENT_CHECK=1
@@ -215,26 +243,19 @@ You can forward arguments in whatever order you like, you simply need to ensure
215
243
  -if-replica-exists
216
244
  Returns exit code of 0 if no backups found.
217
245
 
218
- -parallelism NUM
219
- Determines the number of WAL files downloaded in parallel.
220
- Defaults to 8
221
-
222
- -replica NAME
223
- Restore from a specific replica.
224
- Defaults to replica with latest data.
225
-
226
- -generation NAME
227
- Restore from a specific generation.
228
- Defaults to generation with latest data.
229
-
230
- -index NUM
231
- Restore up to a specific WAL index (inclusive).
232
- Defaults to use the highest available index.
246
+ -txid TXID
247
+ Restore through a specific 16-character hexadecimal transaction ID.
233
248
 
234
249
  -timestamp TIMESTAMP
235
250
  Restore to a specific point-in-time.
236
251
  Defaults to use the latest available backup.
237
252
 
253
+ -json
254
+ Print the restore result as JSON.
255
+
256
+ -dry-run
257
+ Print the restore plan without writing the database.
258
+
238
259
  -config PATH
239
260
  Specifies the configuration file.
240
261
  Defaults to /etc/litestream.yml
@@ -387,79 +408,59 @@ bin/rails litestream:databases
387
408
  This will return a list of databases and their configured replicas:
388
409
 
389
410
  ```
390
- path replicas
411
+ path replica
391
412
  /Users/you/Code/your-app/storage/production.sqlite3 s3
392
413
  ```
393
414
 
394
- You can also list the generations of a specific database:
415
+ You can inspect local replication status for all databases or one specific database:
395
416
 
396
417
  ```shell
397
- bin/rails litestream:generations -- --database=storage/production.sqlite3
418
+ bin/rails litestream:status -- --database=storage/production.sqlite3
398
419
  ```
399
420
 
400
- This will list all generations for the specified database, including stats about their lag behind the primary database and the time range they cover:
421
+ This reads local state and does not require a running Litestream process:
401
422
 
402
423
  ```
403
- name generation lag start end
404
- s3 a295b16a796689f3 -156ms 2024-04-17T00:01:19Z 2024-04-17T00:01:19Z
424
+ database status local txid wal size
425
+ /Users/you/Code/your-app/storage/production.sqlite3 ok 000000000000000a 128 kB
405
426
  ```
406
427
 
407
- You can list the snapshots available for a database:
428
+ You can list all remote LTX files, including the level-9 snapshot:
408
429
 
409
430
  ```shell
410
- bin/rails litestream:snapshots -- --database=storage/production.sqlite3
431
+ bin/rails litestream:ltx -- --database=storage/production.sqlite3 --level=all
411
432
  ```
412
433
 
413
- This command lists snapshots available for that specified database:
434
+ The command returns the compaction level, transaction range, byte size, and creation time:
414
435
 
415
436
  ```
416
- replica generation index size created
417
- s3 a295b16a796689f3 1 4645465 2024-04-17T00:01:19Z
418
- ```
419
-
420
- Finally, you can list the wal files available for a database:
421
-
422
- ```shell
423
- bin/rails litestream:wal -- --database=storage/production.sqlite3
424
- ```
425
-
426
- This command lists wal files available for that specified database:
427
-
428
- ```
429
- replica generation index offset size created
430
- s3 a295b16a796689f3 1 0 2036 2024-04-17T00:01:19Z
437
+ level min_txid max_txid size created
438
+ 0 0000000000000008 000000000000000a 1013 2026-09-08T03:16:43Z
431
439
  ```
432
440
 
433
441
  ### Running commands from Ruby
434
442
 
435
443
  In addition to the provided rake tasks, you can also run Litestream commands directly from Ruby. The gem provides a `Litestream::Commands` module that wraps the Litestream CLI commands. This is particularly useful for the introspection commands, as you can use the output in your Ruby code.
436
444
 
437
- The `Litestream::Commands.databases` method returns an array of hashes with the "path" and "replicas" keys for each database:
445
+ Pass `json: true` to return the 0.5 CLI's JSON values with string keys. The `Litestream::Commands.databases` method returns each database path and replica type:
438
446
 
439
447
  ```ruby
440
- Litestream::Commands.databases
441
- # => [{"path"=>"/Users/you/Code/your-app/storage/production.sqlite3", "replicas"=>"s3"}]
448
+ Litestream::Commands.databases(json: true)
449
+ # => [{"path"=>"/Users/you/Code/your-app/storage/production.sqlite3", "replica"=>"s3"}]
442
450
  ```
443
451
 
444
- The `Litestream::Commands.generations` method returns an array of hashes with the "name", "generation", "lag", "start", and "end" keys for each generation:
452
+ The `status` method returns local state without connecting to the replica or requiring a daemon:
445
453
 
446
454
  ```ruby
447
- Litestream::Commands.generations('storage/production.sqlite3')
448
- # => [{"name"=>"s3", "generation"=>"5f4341bc3d22d615", "lag"=>"3s", "start"=>"2024-04-17T19:48:09Z", "end"=>"2024-04-17T19:48:09Z"}]
455
+ Litestream::Commands.status("storage/production.sqlite3", json: true)
456
+ # => [{"database"=>"/Users/you/Code/your-app/storage/production.sqlite3", "status"=>"ok", "local_txid"=>"000000000000000a", "wal_size"=>"128 kB"}]
449
457
  ```
450
458
 
451
- The `Litestream::Commands.snapshots` method returns an array of hashes with the "replica", "generation", "index", "size", and "created" keys for each snapshot:
459
+ The `ltx` method lists remote LTX files. An unreplicated database returns an empty array:
452
460
 
453
461
  ```ruby
454
- Litestream::Commands.snapshots('storage/production.sqlite3')
455
- # => [{"replica"=>"s3", "generation"=>"5f4341bc3d22d615", "index"=>"0", "size"=>"4645465", "created"=>"2024-04-17T19:48:09Z"}]
456
- ```
457
-
458
- The `Litestream::Commands.wal` method returns an array of hashes with the "replica", "generation", "index", "offset","size", and "created" keys for each wal:
459
-
460
- ```ruby
461
- Litestream::Commands.wal('storage/production.sqlite3')
462
- # => [{"replica"=>"s3", "generation"=>"5f4341bc3d22d615", "index"=>"0", "offset"=>"0", "size"=>"2036", "created"=>"2024-04-17T19:48:09Z"}]
462
+ Litestream::Commands.ltx("storage/production.sqlite3", json: true, "--level" => "all")
463
+ # => [{"level"=>0, "min_txid"=>"0000000000000008", "max_txid"=>"000000000000000a", "size"=>1013, "timestamp"=>"2026-09-08T03:16:43Z"}]
463
464
  ```
464
465
 
465
466
  You can also restore a database programmatically using the `Litestream::Commands.restore` method, which returns the path to the restored database:
@@ -479,14 +480,29 @@ The full set of commands available to the `litestream` executable are covered in
479
480
 
480
481
  ```shell
481
482
  litestream databases [arguments]
482
- litestream generations [arguments] DB_PATH|REPLICA_URL
483
+ litestream info [arguments]
484
+ litestream list [arguments]
485
+ litestream ltx [arguments] DB_PATH
486
+ litestream register [arguments]
483
487
  litestream replicate [arguments]
484
- litestream restore [arguments] DB_PATH|REPLICA_URL
485
- litestream snapshots [arguments] DB_PATH|REPLICA_URL
488
+ litestream reset [arguments]
489
+ litestream restore [arguments] DB_PATH
490
+ litestream start [arguments]
491
+ litestream status [arguments] [DB_PATH]
492
+ litestream stop [arguments]
493
+ litestream sync [arguments]
494
+ litestream unregister [arguments]
486
495
  litestream version
487
- litestream wal [arguments] DB_PATH|REPLICA_URL
488
496
  ```
489
497
 
498
+ ### Upgrading from 0.3
499
+
500
+ Litestream 0.5 replicates each database to a single `replica:` map and moves retention and snapshot settings into the root-level `snapshot:` block. A `replicas:` list with one entry still works. A list with more than one entry stops `litestream replicate` from starting ("multiple replicas on a single database are no longer supported"), and when the Puma plugin sees Litestream exit it stops Puma, so reduce each database to one replica before upgrading. The `databases` command and Ruby method now return a `"replica"` key instead of `"replicas"`.
501
+
502
+ The removed `generations`, `snapshots`, and `wal` command methods and rake tasks now raise an error pointing to `ltx`, which replaces all three forms of remote backup introspection.
503
+
504
+ The 0.5 `restore` command auto-detects both 0.3 and LTX backups and prefers whichever backup is newer. Existing 0.3 `generations/` objects are left in place; you can delete them after the LTX history covers your full retention window. To roll back, reinstall a 0.15.x release of this gem, which bundles Litestream 0.3.
505
+
490
506
  ### Using in development
491
507
 
492
508
  By default, if you install the gem and configure via `puma.rb` or `Procfile`, Litestream will not start in development.
@@ -1,5 +1,7 @@
1
1
  module Litestream
2
2
  class ApplicationController < Litestream.base_controller_class.constantize
3
+ layout "litestream/application"
4
+
3
5
  protect_from_forgery with: :exception
4
6
 
5
7
  if Litestream.password
@@ -56,63 +56,51 @@
56
56
  </div>
57
57
 
58
58
  <br />
59
- <section id="generations" class="ml-6">
60
- <% database['generations'].each do |generation| %>
61
- <details id="<%= generation['generation'] %>" open="open">
62
- <summary class="cursor-pointer rounded p-2 hover:bg-gray-50 dark:hover:bg-gray-800">
63
- <code><%= generation['generation'] %></code>
64
- (<em><%= generation['lag'] %> lag</em>)
65
- </summary>
59
+ <section class="ml-6">
60
+ <% if database['error'] %>
61
+ <p class="text-red-600 dark:text-red-400"><%= database['error'] %></p>
62
+ <% else %>
63
+ <dl class="grid grid-cols-[fit-content(100%)_1fr] gap-x-4">
64
+ <dt class="font-bold">Status</dt>
65
+ <dd><%= database.dig('status', 'status') %></dd>
66
+ <dt class="font-bold">Local TXID</dt>
67
+ <dd><code><%= database.dig('status', 'local_txid') %></code></dd>
68
+ <dt class="font-bold">WAL size</dt>
69
+ <dd><%= number_to_human_size database.dig('status', 'wal_size') %></dd>
70
+ </dl>
66
71
 
67
- <dl class="ml-7 grid grid-cols-[fit-content(100%)_1fr] gap-x-4">
68
- <dt class="font-bold">Start</dt>
69
- <dd class="">
70
- <abbr title="<%= generation['start'] %>" class="underline decoration-dashed decoration-gray-500 cursor-help">
71
- <time datetime="<%= generation['start'] %>"><%= DateTime.parse(generation['start']).to_formatted_s(:db) %></time>
72
- </abbr>
73
- </dd>
72
+ <br />
73
+ <% if database['ltx'] == [] %>
74
+ <p>No LTX files yet</p>
75
+ <% else %>
76
+ <table class="min-w-full divide-y divide-gray-300">
77
+ <thead>
78
+ <tr>
79
+ <th scope="col" class="whitespace-nowrap px-2 py-2 text-right text-sm font-semibold text-gray-900 dark:text-gray-100">Level</th>
80
+ <th scope="col" class="whitespace-nowrap px-2 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Min TXID</th>
81
+ <th scope="col" class="whitespace-nowrap px-2 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Max TXID</th>
82
+ <th scope="col" class="whitespace-nowrap px-2 py-2 text-right text-sm font-semibold text-gray-900 dark:text-gray-100">Size</th>
83
+ <th scope="col" class="whitespace-nowrap px-2 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Created</th>
84
+ </tr>
85
+ </thead>
74
86
 
75
- <dt class="font-bold">End</dt>
76
- <dd class="">
77
- <abbr title="<%= generation['end'] %>" class="underline decoration-dashed decoration-gray-500 cursor-help">
78
- <time datetime="<%= generation['end'] %>"><%= DateTime.parse(generation['end']).to_formatted_s(:db) %></time>
79
- </abbr>
80
- </dd>
81
-
82
- <div class="col-span-2">
83
- <dt class="font-bold">Snapshots</dt>
84
- <dd class="">
85
- <table class="min-w-full divide-y divide-gray-300">
86
- <thead>
87
- <tr>
88
- <th scope="col" class="whitespace-nowrap px-2 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Created at</th>
89
- <th scope="col" class="whitespace-nowrap px-2 py-2 text-right text-sm font-semibold text-gray-900 dark:text-gray-100">Index</th>
90
- <th scope="col" class="whitespace-nowrap px-2 py-2 text-right text-sm font-semibold text-gray-900 dark:text-gray-100">Size</th>
91
- </tr>
92
- </thead>
93
-
94
- <tbody class="bg-white dark:bg-gray-900">
95
- <% generation['snapshots'].each do |snapshot| %>
96
- <tr class="align-top even:bg-gray-50 dark:even:bg-gray-800">
97
- <td scope="col" class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100">
98
- <abbr title="<%= snapshot['created'] %>" class="underline decoration-dashed decoration-gray-500 cursor-help">
99
- <time datetime="<%= snapshot['created'] %>"><%= DateTime.parse(snapshot['created']).to_formatted_s(:db) %></time>
100
- </abbr>
101
- </td>
102
- <td scope="col" class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100 text-right">
103
- <%= snapshot['index'] %>
104
- </td>
105
- <td scope="col" class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100 text-right">
106
- <%= number_to_human_size snapshot['size'] %>
107
- </td>
108
- </tr>
109
- <% end %>
110
- </tbody>
111
- </table>
112
- </dd>
113
- </div>
114
- </dl>
115
- </details>
87
+ <tbody class="bg-white dark:bg-gray-900">
88
+ <% database['ltx'].each do |ltx| %>
89
+ <tr class="align-top even:bg-gray-50 dark:even:bg-gray-800">
90
+ <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100 text-right"><%= ltx['level'] %></td>
91
+ <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100"><code><%= ltx['min_txid'] %></code></td>
92
+ <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100"><code><%= ltx['max_txid'] %></code></td>
93
+ <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100 text-right"><%= number_to_human_size ltx['size'] %></td>
94
+ <td class="whitespace-nowrap px-2 py-2 text-sm text-gray-900 dark:text-gray-100">
95
+ <abbr title="<%= ltx['timestamp'] %>" class="underline decoration-dashed decoration-gray-500 cursor-help">
96
+ <time datetime="<%= ltx['timestamp'] %>"><%= DateTime.parse(ltx['timestamp']).to_formatted_s(:db) %></time>
97
+ </abbr>
98
+ </td>
99
+ </tr>
100
+ <% end %>
101
+ </tbody>
102
+ </table>
103
+ <% end %>
116
104
  <% end %>
117
105
  </section>
118
106
  </li>
Binary file
@@ -0,0 +1 @@
1
+ cfb371176d164437ae869f8351cfde49bd1804ae71c61923f75c9cba9c9c006d
@@ -1,3 +1,5 @@
1
+ require "json"
2
+ require "open3"
1
3
  require_relative "upstream"
2
4
 
3
5
  module Litestream
@@ -20,6 +22,9 @@ module Litestream
20
22
  # raised when a litestream command fails
21
23
  CommandFailedException = Class.new(StandardError)
22
24
 
25
+ # raised when a litestream command times out
26
+ CommandTimeoutException = Class.new(CommandFailedException)
27
+
23
28
  module Output
24
29
  class << self
25
30
  def format(data)
@@ -47,7 +52,10 @@ module Litestream
47
52
  litestream_install_dir = ENV["LITESTREAM_INSTALL_DIR"]
48
53
  if litestream_install_dir
49
54
  if File.directory?(litestream_install_dir)
50
- warn "NOTE: using LITESTREAM_INSTALL_DIR to find litestream executable: #{litestream_install_dir}"
55
+ unless @litestream_install_dir_noted
56
+ warn "NOTE: using LITESTREAM_INSTALL_DIR to find litestream executable: #{litestream_install_dir}"
57
+ @litestream_install_dir_noted = true
58
+ end
51
59
  exe_path = litestream_install_dir
52
60
  exe_file = File.expand_path(File.join(litestream_install_dir, "litestream"))
53
61
  else
@@ -94,12 +102,14 @@ module Litestream
94
102
  exe_file
95
103
  end
96
104
 
97
- # Replicate can be run either as a fork or in the same process, depending on the context.
98
- # Puma will start replication as a forked process, while running replication from a rake
99
- # tasks won't.
105
+ # Replicate runs either as a child process or in the foreground, depending on the context.
106
+ # The Puma plugin starts it as a child and gets its pid back; the rake task runs it in the
107
+ # foreground and streams its output.
100
108
  def replicate(async: false, **argv)
101
109
  cmd = prepare("replicate", argv)
102
110
  run_replicate(cmd, async: async)
111
+ rescue CommandFailedException
112
+ raise
103
113
  rescue
104
114
  raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`"
105
115
  end
@@ -114,35 +124,48 @@ module Litestream
114
124
  execute("databases", argv)
115
125
  end
116
126
 
117
- def generations(database, **argv)
118
- raise DatabaseRequiredException, "database argument is required for generations command, e.g. litestream:generations -- --database=path/to/database.sqlite" if database.nil?
127
+ def ltx(database, **argv)
128
+ raise DatabaseRequiredException, "database argument is required for ltx command, e.g. litestream:ltx -- --database=path/to/database.sqlite" if database.nil?
119
129
 
120
- execute("generations", argv, database)
130
+ execute("ltx", argv, database)
121
131
  end
122
132
 
123
- def snapshots(database, **argv)
124
- raise DatabaseRequiredException, "database argument is required for snapshots command, e.g. litestream:snapshots -- --database=path/to/database.sqlite" if database.nil?
133
+ # Litestream 0.5 filters `status` by the absolute database path, while
134
+ # `ltx` and `restore` match the path as written in the config file, so a
135
+ # relative path is expanded here to keep the documented
136
+ # `--database=storage/production.sqlite3` form working.
137
+ def status(database = nil, **argv)
138
+ execute("status", argv, database && File.expand_path(database))
139
+ end
125
140
 
126
- execute("snapshots", argv, database)
141
+ def generations(*)
142
+ raise CommandFailedException, "`generations` was removed in Litestream 0.5; use `ltx` (see README, Upgrading from 0.3)"
127
143
  end
128
144
 
129
- def wal(database, **argv)
130
- raise DatabaseRequiredException, "database argument is required for wal command, e.g. litestream:wal -- --database=path/to/database.sqlite" if database.nil?
145
+ def snapshots(*)
146
+ raise CommandFailedException, "`snapshots` was removed in Litestream 0.5; use `ltx` (see README, Upgrading from 0.3)"
147
+ end
131
148
 
132
- execute("wal", argv, database)
149
+ def wal(*)
150
+ raise CommandFailedException, "`wal` was removed in Litestream 0.5; use `ltx` (see README, Upgrading from 0.3)"
133
151
  end
134
152
 
135
153
  private
136
154
 
137
155
  def execute(command, argv = {}, database = nil, tabled_output: true)
138
- cmd = prepare(command, argv, database)
139
- results = run(cmd, tabled_output: tabled_output)
140
-
141
- if Array === results && results.one? && results[0]["level"] == "ERROR"
142
- raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`; Reason: #{results[0]["error"]}"
156
+ argv = argv.stringify_keys
157
+ timeout = argv.delete("timeout")
158
+ output = if argv.delete("json")
159
+ argv["-json"] = nil
160
+ :json
161
+ elsif tabled_output
162
+ :table
143
163
  else
144
- results
164
+ :raw
145
165
  end
166
+
167
+ cmd = prepare(command, argv, database)
168
+ run(cmd, output: output, timeout: timeout)
146
169
  end
147
170
 
148
171
  def prepare(command, argv = {}, database = nil)
@@ -154,29 +177,101 @@ module Litestream
154
177
 
155
178
  args = {
156
179
  "--config" => Litestream.config_path.to_s
157
- }.merge(argv.stringify_keys).to_a.flatten.compact
158
- cmd = [executable, command, *args, database].compact
180
+ }.merge(argv.stringify_keys).to_a.flatten.compact.map(&:to_s)
181
+ cmd = [executable, command, *args, database].compact.map(&:to_s)
159
182
  puts cmd.inspect if ENV["DEBUG"]
160
183
 
161
184
  cmd
162
185
  end
163
186
 
164
- def run(cmd, tabled_output:)
165
- stdout = `#{cmd.join(" ")}`.chomp
166
- return stdout unless tabled_output
187
+ # Runs the command without a shell and returns its parsed stdout. A non-zero
188
+ # exit raises with stderr. With a timeout, the command runs in its own
189
+ # process group and is killed (TERM, then KILL) when the deadline passes.
190
+ def run(cmd, output:, timeout: nil)
191
+ stdin, stdout, stderr, wait_thread = Open3.popen3(*cmd, pgroup: true)
192
+ stdin.close
193
+ stdout_reader = Thread.new { stdout.read }
194
+ stderr_reader = Thread.new { stderr.read }
195
+
196
+ # The readers finish when the last process holding the pipes exits, so
197
+ # waiting on them covers descendants the direct child may have left behind.
198
+ unless wait_thread.join(timeout) && stdout_reader.join(timeout) && stderr_reader.join(timeout)
199
+ kill_process_group("TERM", wait_thread.pid)
200
+ wait_thread.join(1)
201
+ kill_process_group("KILL", wait_thread.pid)
202
+ wait_thread.join
203
+ [stdout_reader, stderr_reader].each(&:join)
204
+ raise CommandTimeoutException, "Failed to execute `#{cmd[1]}`: timed out after #{timeout} seconds"
205
+ end
206
+
207
+ status = wait_thread.value
208
+ unless status.success?
209
+ raise CommandFailedException, "Failed to execute `#{cmd[1]}` (exit status #{status.exitstatus}): #{stderr_reader.value.strip[0, 500]}"
210
+ end
211
+
212
+ case output
213
+ when :json then parse_json(cmd, stdout_reader.value)
214
+ when :table then parse_table(stdout_reader.value)
215
+ else stdout_reader.value
216
+ end
217
+ ensure
218
+ [stdout_reader, stderr_reader].each { |reader| reader&.join }
219
+ [stdin, stdout, stderr].each { |io| io&.close unless io&.closed? }
220
+ end
221
+
222
+ # macOS answers EPERM rather than ESRCH when every process left in the
223
+ # group has exited but not yet been reaped.
224
+ def kill_process_group(signal, pid)
225
+ Process.kill(signal, -pid)
226
+ rescue Errno::ESRCH, Errno::EPERM
227
+ end
228
+
229
+ # Two opt-in restore skips (-if-db-not-exists when the output exists,
230
+ # -if-replica-exists with no backups) exit 0 and print one logfmt line on
231
+ # stdout instead of JSON. They come back as {"skipped" => true, "message" => ...}.
232
+ def parse_json(cmd, stdout)
233
+ stdout = stdout.strip
234
+ return if stdout.empty?
235
+ return JSON.parse(stdout) if stdout.start_with?("{", "[")
236
+
237
+ skipped = stdout.match(/\Atime=\S+ level=\S+ msg=(?:"([^"]*)"|(\S+))/)
238
+ return {"skipped" => true, "message" => skipped[1] || skipped[2]} if skipped
239
+
240
+ raise CommandFailedException, "Unexpected output from `#{cmd[1]}`: #{stdout.lines.first.to_s.strip[0, 200]}"
241
+ end
242
+
243
+ # Litestream pads columns with at least two spaces, and some headers and
244
+ # values hold a single one ("local txid", "231 kB", "no database").
245
+ def parse_table(stdout)
246
+ keys, *rows = stdout.strip.split("\n").map { _1.strip.split(/\s{2,}/) }
247
+ return [] unless keys
167
248
 
168
- keys, *rows = stdout.split("\n").map { _1.split(/\s+/) }
169
249
  rows.map { keys.zip(_1).to_h }
170
250
  end
171
251
 
252
+ # Async replication returns the pid of the litestream process itself so a
253
+ # supervisor can signal and wait on it. Process.spawn is fork-and-exec in
254
+ # one call, so the pid it returns is the one that becomes litestream. The
255
+ # previous `exec(*cmd) if fork.nil?` also execs in a child but returns
256
+ # into the parent too, and the Puma plugin wrapped that in a fork of its
257
+ # own, so it recorded a pid that exited immediately while the daemon was
258
+ # reparented to init and outlived Puma.
172
259
  def run_replicate(cmd, async:)
173
260
  if async
174
- exec(*cmd) if fork.nil?
261
+ Process.spawn(*cmd)
175
262
  else
176
263
  # When running in-process, we capture output continuously and write to stdout.
177
264
  IO.popen(cmd, err: [:child, :out]) do |io|
178
265
  io.each_line { |line| puts line }
179
266
  end
267
+ status = $?
268
+ # `replicate` runs until it is signalled, so a signal is how it is
269
+ # meant to end. A non-zero exit is litestream failing to start or
270
+ # dying on its own, which is invisible today: the task exits 0 and a
271
+ # supervisor sees a clean stop rather than a crash to restart.
272
+ if status && !status.success? && !status.signaled?
273
+ raise CommandFailedException, "Failed to execute `replicate` (exit status #{status.exitstatus})"
274
+ end
180
275
  end
181
276
  end
182
277
  end
@@ -9,13 +9,18 @@
9
9
  # `replicate` command.
10
10
  #
11
11
  # For more details, see: https://litestream.io/reference/config/
12
+ # Retention and snapshot settings are global in Litestream 0.5.
13
+ snapshot:
14
+ interval: 24h
15
+ retention: 24h
16
+
12
17
  dbs:
13
18
  <%- production_sqlite_databases.each do |database| -%>
14
19
  - path: <%= database %>
15
- replicas:
16
- - type: s3
17
- bucket: $LITESTREAM_REPLICA_BUCKET
18
- path: <%= database %>
19
- access-key-id: $LITESTREAM_ACCESS_KEY_ID
20
- secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
20
+ replica:
21
+ type: s3
22
+ bucket: $LITESTREAM_REPLICA_BUCKET
23
+ path: <%= database %>
24
+ access-key-id: $LITESTREAM_ACCESS_KEY_ID
25
+ secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
21
26
  <%- end -%>
@@ -1,14 +1,25 @@
1
1
  module Litestream
2
2
  module Upstream
3
- VERSION = "v0.3.13"
3
+ VERSION = "0.5.17"
4
4
 
5
5
  # rubygems platform name => upstream release filename
6
6
  NATIVE_PLATFORMS = {
7
7
  "aarch64-linux" => "litestream-#{VERSION}-linux-arm64.tar.gz",
8
- "arm64-darwin" => "litestream-#{VERSION}-darwin-arm64.zip",
8
+ "arm64-darwin" => "litestream-#{VERSION}-darwin-arm64.tar.gz",
9
9
  "arm64-linux" => "litestream-#{VERSION}-linux-arm64.tar.gz",
10
- "x86_64-darwin" => "litestream-#{VERSION}-darwin-amd64.zip",
11
- "x86_64-linux" => "litestream-#{VERSION}-linux-amd64.tar.gz"
10
+ "x86_64-darwin" => "litestream-#{VERSION}-darwin-x86_64.tar.gz",
11
+ "x86_64-linux" => "litestream-#{VERSION}-linux-x86_64.tar.gz"
12
12
  }
13
+
14
+ CHECKSUMS = {
15
+ "litestream-0.5.17-linux-x86_64.tar.gz" => "cfb371176d164437ae869f8351cfde49bd1804ae71c61923f75c9cba9c9c006d",
16
+ "litestream-0.5.17-linux-arm64.tar.gz" => "f8ca4a050095c1efbda2c4365172e61bf9d955ea0d9ac42f448b52e51819baa5",
17
+ "litestream-0.5.17-darwin-x86_64.tar.gz" => "891875af09db152e93a4b31a8a79f538ce7ce702c132803cfe0a831e7cb1b7db",
18
+ "litestream-0.5.17-darwin-arm64.tar.gz" => "e211f68ff7658d19f193f2914417afdf8f89a053ff8f263e5d6b3b1d3bbc7b08"
19
+ }
20
+
21
+ def self.download_url(filename)
22
+ "https://github.com/benbjohnson/litestream/releases/download/v#{VERSION}/#{filename}"
23
+ end
13
24
  end
14
25
  end
@@ -1,3 +1,3 @@
1
1
  module Litestream
2
- VERSION = "0.14.0"
2
+ VERSION = "0.16.0"
3
3
  end
data/lib/litestream.rb CHANGED
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "etc"
3
4
  require "sqlite3"
5
+ require "time"
4
6
 
5
7
  module Litestream
6
8
  VerificationFailure = Class.new(StandardError)
7
9
 
10
+ PROCESS_STATES = {
11
+ "D" => "uninterruptible",
12
+ "I" => "idle",
13
+ "R" => "running",
14
+ "S" => "sleeping",
15
+ "T" => "stopped",
16
+ "U" => "uninterruptible",
17
+ "Z" => "zombie"
18
+ }.freeze
19
+ private_constant :PROCESS_STATES
20
+
8
21
  class << self
9
22
  attr_writer :configuration
10
23
 
@@ -107,20 +120,18 @@ module Litestream
107
120
  end
108
121
 
109
122
  def databases
110
- databases = Commands.databases
123
+ databases = Commands.databases(json: true)
111
124
 
112
125
  databases.each do |db|
113
- generations = Commands.generations(db["path"])
114
- snapshots = Commands.snapshots(db["path"])
115
- db["path"] = db["path"].gsub(Rails.root.to_s, "[ROOT]")
116
-
117
- db["generations"] = generations.map do |generation|
118
- id = generation["generation"]
119
- replica = generation["name"]
120
- generation["snapshots"] = snapshots.select { |snapshot| snapshot["generation"] == id && snapshot["replica"] == replica }
121
- .map { |s| s.slice("index", "size", "created") }
122
- generation.slice("generation", "name", "lag", "start", "end", "snapshots")
126
+ path = db["path"]
127
+ begin
128
+ db["status"] = Commands.status(path, json: true).first
129
+ db["ltx"] = Commands.ltx(path, :json => true, "--level" => "all")
130
+ rescue Commands::CommandFailedException => error
131
+ db["error"] = error.message
123
132
  end
133
+
134
+ db["path"] = path.gsub(Rails.root.to_s, "[ROOT]")
124
135
  end
125
136
  end
126
137
 
@@ -163,30 +174,58 @@ module Litestream
163
174
  info
164
175
  end
165
176
 
177
+ # Linux has every process under /proc, even in images that ship no `ps`
178
+ # (Debian slim) or BusyBox's, which lacks `-x` and `lstart` (Alpine).
166
179
  def process_info
167
- litestream_replicate_ps = `ps -ax | grep litestream | grep replicate`
168
- exit_code = $?.exitstatus
169
- return unless exit_code.zero?
180
+ File.directory?("/proc/self") ? proc_process_info : ps_process_info
181
+ end
170
182
 
171
- info = {}
172
- litestream_replicate_ps.chomp.split("\n").each do |line|
173
- next unless line.include?("litestream replicate")
174
-
175
- pid, * = line.split(" ")
176
- info[:pid] = pid
177
- state, _, lstart = `ps -o "state,lstart" #{pid}`.chomp.split("\n").last.partition(/\s+/)
178
-
179
- info[:status] = case state[0]
180
- when "I" then "idle"
181
- when "R" then "running"
182
- when "S" then "sleeping"
183
- when "T" then "stopped"
184
- when "U" then "uninterruptible"
185
- when "Z" then "zombie"
186
- end
187
- info[:started] = DateTime.strptime(lstart.strip, "%a %b %d %H:%M:%S %Y")
183
+ def proc_process_info(proc_dir = "/proc")
184
+ boot_time = File.foreach(File.join(proc_dir, "stat")).grep(/\Abtime /).first.split.last.to_i
185
+
186
+ Dir.glob(File.join(proc_dir, "[0-9]*")).each do |dir|
187
+ executable, command = File.read(File.join(dir, "cmdline")).split("\0")
188
+ next unless File.basename(executable.to_s) == "litestream" && command == "replicate"
189
+
190
+ # Fields are counted after the command name's closing paren, since the
191
+ # name itself may contain spaces and parens.
192
+ state, *fields = File.read(File.join(dir, "stat")).rpartition(")").last.split
193
+ start_ticks = fields[18].to_i
194
+
195
+ return {
196
+ pid: File.basename(dir),
197
+ status: PROCESS_STATES[state[0]],
198
+ started: Time.at(boot_time + start_ticks.fdiv(Etc.sysconf(Etc::SC_CLK_TCK))).to_datetime
199
+ }
200
+ rescue SystemCallError
201
+ # the process exited while being read, or belongs to another user
202
+ next
188
203
  end
189
- info
204
+ nil
205
+ end
206
+
207
+ # macOS. The command name is matched on its own so that a shell or editor
208
+ # whose arguments mention "litestream replicate" is not mistaken for it.
209
+ def ps_process_info
210
+ output = `ps -axo pid=,stat=,lstart=,comm=`
211
+ return unless $?.success?
212
+
213
+ output.each_line do |line|
214
+ # lstart is always five words ("Mon Jul 1 11:10:58 2024"); comm is last
215
+ # because it may contain spaces.
216
+ pid, state, *lstart, command = line.strip.split(" ", 8)
217
+ next unless File.basename(command.to_s) == "litestream"
218
+ next unless `ps -o args= -p #{pid}`.match?(/litestream replicate(?: |$)/)
219
+
220
+ return {
221
+ pid: pid,
222
+ status: PROCESS_STATES[state[0]],
223
+ started: Time.strptime(lstart.join(" "), "%a %b %d %H:%M:%S %Y").to_datetime
224
+ }
225
+ end
226
+ nil
227
+ rescue Errno::ENOENT
228
+ nil
190
229
  end
191
230
  end
192
231
  end
@@ -2,17 +2,13 @@ require "puma/plugin"
2
2
 
3
3
  # Copied from https://github.com/rails/solid_queue/blob/15408647f1780033dad223d3198761ea2e1e983e/lib/puma/plugin/solid_queue.rb
4
4
  Puma::Plugin.create do
5
- attr_reader :puma_pid, :litestream_pid, :log_writer
5
+ attr_reader :litestream_pid, :log_writer
6
6
 
7
7
  def start(launcher)
8
8
  @log_writer = launcher.log_writer
9
- @puma_pid = $$
10
9
 
11
10
  launcher.events.on_booted do
12
- @litestream_pid = fork do
13
- Thread.new { monitor_puma }
14
- Litestream::Commands.replicate(async: true)
15
- end
11
+ @litestream_pid = Litestream::Commands.replicate(async: true)
16
12
 
17
13
  in_background do
18
14
  monitor_litestream
@@ -33,10 +29,6 @@ Puma::Plugin.create do
33
29
  rescue Errno::ECHILD, Errno::ESRCH
34
30
  end
35
31
 
36
- def monitor_puma
37
- monitor(:puma_dead?, "Detected Puma has gone away, stopping Litestream...")
38
- end
39
-
40
32
  def monitor_litestream
41
33
  monitor(:litestream_dead?, "Detected Litestream has gone away, stopping Puma...")
42
34
  end
@@ -59,10 +51,6 @@ Puma::Plugin.create do
59
51
  true
60
52
  end
61
53
 
62
- def puma_dead?
63
- Process.ppid != puma_pid
64
- end
65
-
66
54
  def log(...)
67
55
  log_writer.log(...)
68
56
  end
@@ -32,40 +32,57 @@ namespace :litestream do
32
32
  puts Litestream::Commands::Output.format(Litestream::Commands.databases(**options))
33
33
  end
34
34
 
35
- desc "List all generations for a database or replica, for example `rake litestream:generations -- -database=storage/production.sqlite3`"
36
- task generations: :environment do
35
+ desc "List all LTX files for a database or replica, for example `rake litestream:ltx -- -database=storage/production.sqlite3`"
36
+ task ltx: :environment do
37
37
  options = parse_argv_options
38
38
  database = options.delete(:"--database") || options.delete(:"-database")
39
39
 
40
- puts Litestream::Commands::Output.format(Litestream::Commands.generations(database, **options))
40
+ puts Litestream::Commands::Output.format(Litestream::Commands.ltx(database, **options))
41
41
  end
42
42
 
43
- desc "List all snapshots for a database or replica, for example `rake litestream:snapshots -- -database=storage/production.sqlite3`"
44
- task snapshots: :environment do
43
+ desc "Show replication status, for example `rake litestream:status -- -database=storage/production.sqlite3`"
44
+ task status: :environment do
45
45
  options = parse_argv_options
46
46
  database = options.delete(:"--database") || options.delete(:"-database")
47
47
 
48
- puts Litestream::Commands::Output.format(Litestream::Commands.snapshots(database, **options))
48
+ puts Litestream::Commands::Output.format(Litestream::Commands.status(database, **options))
49
49
  end
50
50
 
51
- desc "List all wal files for a database or replica, for example `rake litestream:wal -- -database=storage/production.sqlite3`"
52
- task wal: :environment do
53
- options = parse_argv_options
54
- database = options.delete(:"--database") || options.delete(:"-database")
51
+ desc "Explain the removal of the generations command"
52
+ task generations: :environment do
53
+ abort "`generations` was removed in Litestream 0.5; use `ltx` (see README, Upgrading from 0.3)"
54
+ end
55
55
 
56
- puts Litestream::Commands::Output.format(
57
- Litestream::Commands.wal(database, **options)
58
- )
56
+ desc "Explain the removal of the snapshots command"
57
+ task snapshots: :environment do
58
+ abort "`snapshots` was removed in Litestream 0.5; use `ltx` (see README, Upgrading from 0.3)"
59
+ end
60
+
61
+ desc "Explain the removal of the wal command"
62
+ task wal: :environment do
63
+ abort "`wal` was removed in Litestream 0.5; use `ltx` (see README, Upgrading from 0.3)"
59
64
  end
60
65
 
61
66
  private
62
67
 
68
+ # Options after `--` come as `-flag`, `-flag=value` or `-flag value`. Rake
69
+ # also reads a bare word there as one more task to run, so each value gets
70
+ # an empty task of its own to keep Rake from aborting once this one is done.
63
71
  def parse_argv_options
64
72
  options = {}
65
73
  if (separator_index = ARGV.index("--"))
66
- ARGV.slice(separator_index + 1, ARGV.length)
67
- .map { |pair| pair.split("=") }
68
- .each { |opt| options[opt[0]] = opt[1] || nil }
74
+ awaiting_value = nil
75
+ ARGV.drop(separator_index + 1).each do |arg|
76
+ if arg.start_with?("-")
77
+ key, value = arg.split("=", 2)
78
+ options[key] = value
79
+ awaiting_value = value.nil? ? key : nil
80
+ elsif awaiting_value
81
+ options[awaiting_value] = arg
82
+ awaiting_value = nil
83
+ Rake::Task.define_task(arg)
84
+ end
85
+ end
69
86
  end
70
87
  options.symbolize_keys!
71
88
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: litestream
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.16.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Stephen Margheim
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-06-13 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: sqlite3
@@ -23,6 +23,7 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ force_ruby_platform: false
26
27
  - !ruby/object:Gem::Dependency
27
28
  name: actionpack
28
29
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +38,7 @@ dependencies:
37
38
  - - ">="
38
39
  - !ruby/object:Gem::Version
39
40
  version: '7.0'
41
+ force_ruby_platform: false
40
42
  - !ruby/object:Gem::Dependency
41
43
  name: actionview
42
44
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +53,7 @@ dependencies:
51
53
  - - ">="
52
54
  - !ruby/object:Gem::Version
53
55
  version: '7.0'
56
+ force_ruby_platform: false
54
57
  - !ruby/object:Gem::Dependency
55
58
  name: activejob
56
59
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +68,7 @@ dependencies:
65
68
  - - ">="
66
69
  - !ruby/object:Gem::Version
67
70
  version: '7.0'
71
+ force_ruby_platform: false
68
72
  - !ruby/object:Gem::Dependency
69
73
  name: activesupport
70
74
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +83,7 @@ dependencies:
79
83
  - - ">="
80
84
  - !ruby/object:Gem::Version
81
85
  version: '7.0'
86
+ force_ruby_platform: false
82
87
  - !ruby/object:Gem::Dependency
83
88
  name: railties
84
89
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +98,7 @@ dependencies:
93
98
  - - ">="
94
99
  - !ruby/object:Gem::Version
95
100
  version: '7.0'
101
+ force_ruby_platform: false
96
102
  - !ruby/object:Gem::Dependency
97
103
  name: rails
98
104
  requirement: !ruby/object:Gem::Requirement
@@ -107,20 +113,6 @@ dependencies:
107
113
  - - ">="
108
114
  - !ruby/object:Gem::Version
109
115
  version: '0'
110
- - !ruby/object:Gem::Dependency
111
- name: rubyzip
112
- requirement: !ruby/object:Gem::Requirement
113
- requirements:
114
- - - ">="
115
- - !ruby/object:Gem::Version
116
- version: '0'
117
- type: :development
118
- prerelease: false
119
- version_requirements: !ruby/object:Gem::Requirement
120
- requirements:
121
- - - ">="
122
- - !ruby/object:Gem::Version
123
- version: '0'
124
116
  email:
125
117
  - stephen.margheim@gmail.com
126
118
  executables:
@@ -142,6 +134,7 @@ files:
142
134
  - config/routes.rb
143
135
  - exe/litestream
144
136
  - exe/x86_64-linux/litestream
137
+ - exe/x86_64-linux/litestream.sha256
145
138
  - lib/litestream.rb
146
139
  - lib/litestream/commands.rb
147
140
  - lib/litestream/engine.rb
@@ -174,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
174
167
  - !ruby/object:Gem::Version
175
168
  version: '0'
176
169
  requirements: []
177
- rubygems_version: 3.6.3
170
+ rubygems_version: 3.6.7
178
171
  specification_version: 4
179
172
  summary: Integrate Litestream with the RubyGems infrastructure.
180
173
  test_files: []