agent_c 2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +10 -0
- data/.ruby-version +1 -0
- data/CLAUDE.md +21 -0
- data/README.md +360 -0
- data/Rakefile +16 -0
- data/TODO.md +105 -0
- data/agent_c.gemspec +38 -0
- data/docs/batch.md +503 -0
- data/docs/chat-methods.md +156 -0
- data/docs/cost-reporting.md +86 -0
- data/docs/pipeline-tips-and-tricks.md +453 -0
- data/docs/session-configuration.md +274 -0
- data/docs/testing.md +747 -0
- data/docs/tools.md +103 -0
- data/docs/versioned-store.md +840 -0
- data/lib/agent_c/agent/chat.rb +211 -0
- data/lib/agent_c/agent/chat_response.rb +38 -0
- data/lib/agent_c/agent/chats/anthropic_bedrock.rb +48 -0
- data/lib/agent_c/batch.rb +102 -0
- data/lib/agent_c/configs/repo.rb +90 -0
- data/lib/agent_c/context.rb +56 -0
- data/lib/agent_c/costs/data.rb +39 -0
- data/lib/agent_c/costs/report.rb +219 -0
- data/lib/agent_c/db/store.rb +162 -0
- data/lib/agent_c/errors.rb +19 -0
- data/lib/agent_c/pipeline.rb +152 -0
- data/lib/agent_c/pipelines/agent.rb +219 -0
- data/lib/agent_c/processor.rb +98 -0
- data/lib/agent_c/prompts.yml +53 -0
- data/lib/agent_c/schema.rb +71 -0
- data/lib/agent_c/session.rb +206 -0
- data/lib/agent_c/store.rb +72 -0
- data/lib/agent_c/test_helpers.rb +173 -0
- data/lib/agent_c/tools/dir_glob.rb +46 -0
- data/lib/agent_c/tools/edit_file.rb +114 -0
- data/lib/agent_c/tools/file_metadata.rb +43 -0
- data/lib/agent_c/tools/git_status.rb +30 -0
- data/lib/agent_c/tools/grep.rb +119 -0
- data/lib/agent_c/tools/paths.rb +36 -0
- data/lib/agent_c/tools/read_file.rb +94 -0
- data/lib/agent_c/tools/run_rails_test.rb +87 -0
- data/lib/agent_c/tools.rb +61 -0
- data/lib/agent_c/utils/git.rb +87 -0
- data/lib/agent_c/utils/shell.rb +58 -0
- data/lib/agent_c/version.rb +5 -0
- data/lib/agent_c.rb +32 -0
- data/lib/versioned_store/base.rb +314 -0
- data/lib/versioned_store/config.rb +26 -0
- data/lib/versioned_store/stores/schema.rb +127 -0
- data/lib/versioned_store/version.rb +5 -0
- data/lib/versioned_store.rb +5 -0
- data/template/Gemfile +9 -0
- data/template/Gemfile.lock +152 -0
- data/template/README.md +61 -0
- data/template/Rakefile +50 -0
- data/template/bin/rake +27 -0
- data/template/lib/autoload.rb +10 -0
- data/template/lib/config.rb +59 -0
- data/template/lib/pipeline.rb +19 -0
- data/template/lib/prompts.yml +57 -0
- data/template/lib/store.rb +17 -0
- data/template/test/pipeline_test.rb +221 -0
- data/template/test/test_helper.rb +18 -0
- metadata +194 -0
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
# VersionedStore
|
|
2
|
+
|
|
3
|
+
VersionedStore is a versioned SQLite database abstraction built on ActiveRecord that automatically creates snapshots after each transaction. It provides time-travel capabilities, named snapshots, and a clean DSL for defining records and schemas.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- **Automatic Versioning** - Each transaction automatically creates a timestamped snapshot
|
|
8
|
+
- **Time Travel** - Access any previous version of your database
|
|
9
|
+
- **Named Snapshots** - Create and restore from labeled checkpoints
|
|
10
|
+
- **ActiveRecord Integration** - Full ActiveRecord API support (queries, associations, validations)
|
|
11
|
+
- **Schema DSL** - Define tables inline with record definitions
|
|
12
|
+
- **Additive Schema** - Multiple record blocks merge schemas and behaviors
|
|
13
|
+
- **Transaction Safety** - Automatic rollback on errors, no version created on failure
|
|
14
|
+
- **Read-Only Versions** - Historical versions are immutable by default
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# This loads VersionedStore
|
|
20
|
+
# it's a separate gem, but I just inlined it
|
|
21
|
+
# in this project for convenience
|
|
22
|
+
require 'agent_c'
|
|
23
|
+
|
|
24
|
+
# Define your store with records
|
|
25
|
+
class MyStore < VersionedStore::Base
|
|
26
|
+
record(:author) do
|
|
27
|
+
schema do |t|
|
|
28
|
+
t.string(:name)
|
|
29
|
+
t.string(:email)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
has_many :posts, class_name: class_name(:post)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
record(:post) do
|
|
36
|
+
schema do |t|
|
|
37
|
+
t.string(:title)
|
|
38
|
+
t.text(:content)
|
|
39
|
+
t.integer(:author_id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
belongs_to :author, class_name: class_name(:author)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Initialize the store
|
|
47
|
+
store = MyStore.new( dir: "/tmp/my_data" )
|
|
48
|
+
|
|
49
|
+
# Create records
|
|
50
|
+
author = store.transaction do
|
|
51
|
+
store.author.create!(name: "Jane Doe", email: "jane@example.com")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
post = store.transaction do
|
|
55
|
+
store.post.create!(
|
|
56
|
+
title: "My First Post",
|
|
57
|
+
content: "Hello world!",
|
|
58
|
+
author: author
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Browse versions
|
|
63
|
+
puts "Total versions: #{store.versions.count}"
|
|
64
|
+
store.versions.each_with_index do |version, i|
|
|
65
|
+
puts "Version #{i}: #{version.author.count} authors, #{version.post.count} posts"
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Defining Records and Schemas
|
|
70
|
+
|
|
71
|
+
### Inline Schema Definition
|
|
72
|
+
|
|
73
|
+
The most common pattern is defining the schema inline with the record:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
class MyStore < VersionedStore::Base
|
|
77
|
+
record(:user) do
|
|
78
|
+
schema do |t|
|
|
79
|
+
t.string(:name)
|
|
80
|
+
t.string(:email)
|
|
81
|
+
t.integer(:age)
|
|
82
|
+
t.boolean(:active, default: true)
|
|
83
|
+
t.timestamps
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Add instance methods
|
|
87
|
+
def display_name
|
|
88
|
+
"#{name} <#{email}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Add class methods via class_eval
|
|
92
|
+
def self.active_users
|
|
93
|
+
where(active: true)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
store = MyStore.new( dir: "/tmp/users" )
|
|
99
|
+
user = store.user.create!(name: "Alice", email: "alice@example.com", age: 30)
|
|
100
|
+
puts user.display_name # => "Alice <alice@example.com>"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Explicit Table Names
|
|
104
|
+
|
|
105
|
+
By default, the table name is inferred by pluralizing the record name (adds 's'). You can specify a custom table name:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
record(:person, table: :people) do
|
|
109
|
+
schema do |t|
|
|
110
|
+
t.string(:name)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Additive Record Blocks
|
|
116
|
+
|
|
117
|
+
You can define multiple blocks for the same record. They're additive - schemas merge, methods accumulate:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
record(:user) do
|
|
121
|
+
schema do |t|
|
|
122
|
+
t.string(:name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def hello
|
|
126
|
+
"Hello, #{name}!"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
record(:user) do
|
|
131
|
+
schema do |t|
|
|
132
|
+
t.string(:email)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def goodbye
|
|
136
|
+
"Goodbye, #{name}!"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Both columns and both methods are available
|
|
141
|
+
user = store.user.create!(name: "Bob", email: "bob@example.com")
|
|
142
|
+
user.hello # => "Hello, Bob!"
|
|
143
|
+
user.goodbye # => "Goodbye, Bob!"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Using Migrations
|
|
147
|
+
|
|
148
|
+
For complex schema changes or when you need more control, use explicit migrations:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
class MyStore < VersionedStore::Base
|
|
152
|
+
migrate do
|
|
153
|
+
create_table(:users) do |t|
|
|
154
|
+
t.string(:name)
|
|
155
|
+
t.timestamps
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
migrate do
|
|
160
|
+
add_column(:users, :email, :string)
|
|
161
|
+
add_index(:users, :email, unique: true)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
record(:user, table: :users) do
|
|
165
|
+
validates :email, uniqueness: true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Relationships
|
|
171
|
+
|
|
172
|
+
VersionedStore supports standard ActiveRecord associations. Use `class_name` helper to reference other record classes:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
class BlogStore < VersionedStore::Base
|
|
176
|
+
record(:author) do
|
|
177
|
+
schema do |t|
|
|
178
|
+
t.string(:name)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
has_many(
|
|
182
|
+
:posts,
|
|
183
|
+
foreign_key: :author_id,
|
|
184
|
+
class_name: class_name(:post),
|
|
185
|
+
inverse_of: :author
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
record(:post) do
|
|
190
|
+
schema do |t|
|
|
191
|
+
t.string(:title)
|
|
192
|
+
t.text(:content)
|
|
193
|
+
t.integer(:author_id)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
belongs_to(
|
|
197
|
+
:author,
|
|
198
|
+
class_name: class_name(:author),
|
|
199
|
+
inverse_of: :posts
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
has_many(
|
|
203
|
+
:comments,
|
|
204
|
+
foreign_key: :post_id,
|
|
205
|
+
class_name: class_name(:comment),
|
|
206
|
+
dependent: :destroy
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
record(:comment) do
|
|
211
|
+
schema do |t|
|
|
212
|
+
t.text(:body)
|
|
213
|
+
t.integer(:post_id)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
belongs_to(
|
|
217
|
+
:post,
|
|
218
|
+
class_name: class_name(:post)
|
|
219
|
+
)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
store = BlogStore.new( dir: "/tmp/blog" )
|
|
224
|
+
|
|
225
|
+
author = store.transaction do
|
|
226
|
+
store.author.create!(name: "Alice")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
post = store.transaction do
|
|
230
|
+
store.post.create!(
|
|
231
|
+
title: "Hello World",
|
|
232
|
+
content: "My first post",
|
|
233
|
+
author: author
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
comment = store.transaction do
|
|
238
|
+
store.comment.create!(
|
|
239
|
+
body: "Great post!",
|
|
240
|
+
post: post
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Use relationships
|
|
245
|
+
puts post.author.name # => "Alice"
|
|
246
|
+
puts author.posts.count # => 1
|
|
247
|
+
puts post.comments.first.body # => "Great post!"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Transactions and Versioning
|
|
251
|
+
|
|
252
|
+
### Basic Transactions
|
|
253
|
+
|
|
254
|
+
Every transaction creates a new version snapshot:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
store = MyStore.new( dir: "/tmp/data" )
|
|
258
|
+
|
|
259
|
+
store.user.transaction do
|
|
260
|
+
store.user.create!(name: "Alice")
|
|
261
|
+
end
|
|
262
|
+
# Version 1 created
|
|
263
|
+
|
|
264
|
+
store.user.transaction do
|
|
265
|
+
store.user.create!(name: "Bob")
|
|
266
|
+
end
|
|
267
|
+
# Version 2 created
|
|
268
|
+
|
|
269
|
+
puts store.versions.count # => 2
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Nested Transactions
|
|
273
|
+
|
|
274
|
+
Only the outermost transaction creates a version:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
store.transaction do
|
|
278
|
+
user1 = store.user.create!(name: "Alice")
|
|
279
|
+
user2 = store.user.create!(name: "Bob")
|
|
280
|
+
|
|
281
|
+
store.transaction do
|
|
282
|
+
user1.update!(email: "alice@example.com")
|
|
283
|
+
user2.update!(email: "bob@example.com")
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
# Only 1 version created for the entire transaction
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Transaction Rollback
|
|
290
|
+
|
|
291
|
+
If an error occurs, the transaction rolls back and no version is created:
|
|
292
|
+
|
|
293
|
+
```ruby
|
|
294
|
+
initial_count = store.versions.count
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
store.transaction do
|
|
298
|
+
store.user.create!(name: "Alice")
|
|
299
|
+
raise "Something went wrong"
|
|
300
|
+
end
|
|
301
|
+
rescue => e
|
|
302
|
+
# Exception caught
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# No version created - count unchanged
|
|
306
|
+
assert_equal initial_count, store.versions.count
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Disabling Versioning
|
|
310
|
+
|
|
311
|
+
For high-volume operations or testing, disable versioning:
|
|
312
|
+
|
|
313
|
+
```ruby
|
|
314
|
+
store = MyStore.new(config: {
|
|
315
|
+
dir: "/tmp/data",
|
|
316
|
+
versioned: false
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
store.user.transaction { store.user.create!(name: "Alice") }
|
|
320
|
+
store.user.transaction { store.user.create!(name: "Bob") }
|
|
321
|
+
|
|
322
|
+
store.versions.count # => 0
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Accessing Versions
|
|
326
|
+
|
|
327
|
+
### Listing Versions
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
store.versions.each_with_index do |version, i|
|
|
331
|
+
puts "Version #{i}:"
|
|
332
|
+
puts " Users: #{version.user.count}"
|
|
333
|
+
puts " Posts: #{version.post.count}"
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Reading from Versions
|
|
338
|
+
|
|
339
|
+
Version records are read-only:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
# Get version 0 (first snapshot)
|
|
343
|
+
v0 = store.versions[0]
|
|
344
|
+
|
|
345
|
+
user = v0.user.first
|
|
346
|
+
puts user.name
|
|
347
|
+
|
|
348
|
+
# Versions are read-only
|
|
349
|
+
user.readonly? # => true
|
|
350
|
+
|
|
351
|
+
# This will raise ActiveRecord::ReadOnlyRecord
|
|
352
|
+
user.update!(name: "New Name")
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Version Relationships
|
|
356
|
+
|
|
357
|
+
Associations work across versions:
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
# Create author and post
|
|
361
|
+
author = store.transaction do
|
|
362
|
+
store.author.create!(name: "Alice")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
post = store.transaction do
|
|
366
|
+
store.post.create!(title: "Hello", author: author)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Access from version
|
|
370
|
+
v1 = store.versions[1]
|
|
371
|
+
v1_post = v1.post.first
|
|
372
|
+
puts v1_post.author.name # => "Alice"
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
## Named Snapshots
|
|
376
|
+
|
|
377
|
+
Named snapshots provide labeled checkpoints you can restore to:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
store = MyStore.new( dir: "/tmp/data" )
|
|
381
|
+
|
|
382
|
+
# Create initial state
|
|
383
|
+
store.user.transaction { store.user.create!(name: "Alice") }
|
|
384
|
+
|
|
385
|
+
# Create a named snapshot
|
|
386
|
+
store.snapshot("initial_state")
|
|
387
|
+
|
|
388
|
+
# Make more changes
|
|
389
|
+
store.user.transaction { store.user.create!(name: "Bob") }
|
|
390
|
+
store.user.transaction { store.user.create!(name: "Charlie") }
|
|
391
|
+
|
|
392
|
+
# Create another snapshot
|
|
393
|
+
store.snapshot("three_users")
|
|
394
|
+
|
|
395
|
+
# Continue working
|
|
396
|
+
store.user.transaction { store.user.first.update!(name: "Alice Updated") }
|
|
397
|
+
|
|
398
|
+
puts store.user.count # => 3
|
|
399
|
+
|
|
400
|
+
# Restore to "initial_state"
|
|
401
|
+
restored_store = store.restore("initial_state")
|
|
402
|
+
|
|
403
|
+
puts restored_store.user.count # => 1
|
|
404
|
+
puts restored_store.user.first.name # => "Alice"
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Restoring from Versions
|
|
408
|
+
|
|
409
|
+
### Version-Based Restore
|
|
410
|
+
|
|
411
|
+
Restore to a specific version by index:
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
# Create some versions
|
|
415
|
+
store.user.transaction { store.user.create!(name: "Alice") }
|
|
416
|
+
store.user.transaction { store.user.create!(name: "Bob") }
|
|
417
|
+
store.user.transaction { store.user.create!(name: "Charlie") }
|
|
418
|
+
|
|
419
|
+
# Restore to version 1 (after Bob was added)
|
|
420
|
+
restored_store = store.versions[1].restore
|
|
421
|
+
|
|
422
|
+
restored_store.user.count # => 2
|
|
423
|
+
restored_store.user.pluck(:name) # => ["Alice", "Bob"]
|
|
424
|
+
|
|
425
|
+
# Can continue working from restored state
|
|
426
|
+
restored_store.user.transaction do
|
|
427
|
+
restored_store.user.create!(name: "Dave")
|
|
428
|
+
end
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Named Snapshot Restore
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
store.snapshot("checkpoint")
|
|
435
|
+
|
|
436
|
+
# Make changes...
|
|
437
|
+
|
|
438
|
+
# Restore by name
|
|
439
|
+
restored_store = store.restore("checkpoint")
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### How Restore Works
|
|
443
|
+
|
|
444
|
+
When you restore:
|
|
445
|
+
1. The specified version/snapshot is copied to the main database
|
|
446
|
+
2. Versions newer than the restored version are removed (for version-based restore)
|
|
447
|
+
3. A new version is created of the restored state
|
|
448
|
+
4. A new store instance is returned pointing to the restored database
|
|
449
|
+
|
|
450
|
+
## Configuration
|
|
451
|
+
|
|
452
|
+
### Directory-Based Configuration
|
|
453
|
+
|
|
454
|
+
Store the database in a directory with default filename:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
store = MyStore.new( dir: "/tmp/my_data" )
|
|
458
|
+
|
|
459
|
+
# Creates:
|
|
460
|
+
# /tmp/my_data/db.sqlite3
|
|
461
|
+
# /tmp/my_data/db_versions/
|
|
462
|
+
# /tmp/my_data/db_snapshots/
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Path-Based Configuration
|
|
466
|
+
|
|
467
|
+
Specify a custom database filename:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
store = MyStore.new(
|
|
471
|
+
config: { path: "/tmp/my_data/custom.sqlite3"}
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Creates:
|
|
475
|
+
# /tmp/my_data/custom.sqlite3
|
|
476
|
+
# /tmp/my_data/custom_versions/
|
|
477
|
+
# /tmp/my_data/custom_snapshots/
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### Multiple Databases in Same Directory
|
|
481
|
+
|
|
482
|
+
Use path-based configuration for multiple databases:
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
store1 = MyStore.new( path: "/tmp/data/first.sqlite3" )
|
|
486
|
+
store2 = MyStore.new( path: "/tmp/data/second.sqlite3" )
|
|
487
|
+
|
|
488
|
+
# Creates separate version and snapshot directories:
|
|
489
|
+
# /tmp/data/first.sqlite3
|
|
490
|
+
# /tmp/data/first_versions/
|
|
491
|
+
# /tmp/data/first_snapshots/
|
|
492
|
+
# /tmp/data/second.sqlite3
|
|
493
|
+
# /tmp/data/second_versions/
|
|
494
|
+
# /tmp/data/second_snapshots/
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### Hash Configuration
|
|
498
|
+
|
|
499
|
+
Pass configuration as a hash for convenience:
|
|
500
|
+
|
|
501
|
+
```ruby
|
|
502
|
+
store = MyStore.new(config: {
|
|
503
|
+
dir: "/tmp/data",
|
|
504
|
+
logger: Logger.new($stdout),
|
|
505
|
+
versioned: true
|
|
506
|
+
})
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### Configuration Options
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
{
|
|
513
|
+
# Required: specify either dir or path
|
|
514
|
+
dir: "/tmp/data", # Directory for database
|
|
515
|
+
path: "/tmp/data/db.sqlite3", # Or full path to database file
|
|
516
|
+
|
|
517
|
+
# Optional
|
|
518
|
+
logger: Logger.new($stdout), # Logger for debugging
|
|
519
|
+
versioned: true # Enable/disable versioning
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
## Accessing Store from Records
|
|
524
|
+
|
|
525
|
+
Both record classes and instances have access to the store:
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
class MyStore < VersionedStore::Base
|
|
529
|
+
record(:user) do
|
|
530
|
+
schema do |t|
|
|
531
|
+
t.string(:name)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def create_post(title)
|
|
535
|
+
# Access store from instance
|
|
536
|
+
store.post.create!(title: title, user: self)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def self.with_posts
|
|
540
|
+
# Access store from class
|
|
541
|
+
joins(:posts).distinct
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
record(:post) do
|
|
546
|
+
schema do |t|
|
|
547
|
+
t.string(:title)
|
|
548
|
+
t.integer(:user_id)
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
belongs_to :user, class_name: class_name(:user)
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
store = MyStore.new( dir: "/tmp/data" )
|
|
556
|
+
|
|
557
|
+
user = store.user.create!(name: "Alice")
|
|
558
|
+
post = user.create_post("Hello World")
|
|
559
|
+
|
|
560
|
+
# Class-level store access
|
|
561
|
+
store.user.store == store # => true
|
|
562
|
+
|
|
563
|
+
# Instance-level store access
|
|
564
|
+
user.store == store # => true
|
|
565
|
+
post.store == store # => true
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
## Advanced Usage
|
|
569
|
+
|
|
570
|
+
### Custom Validations
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
record(:user) do
|
|
574
|
+
schema do |t|
|
|
575
|
+
t.string(:email)
|
|
576
|
+
t.string(:name)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
validates :email, presence: true, uniqueness: true
|
|
580
|
+
validates :name, length: { minimum: 2 }
|
|
581
|
+
|
|
582
|
+
before_save :normalize_email
|
|
583
|
+
|
|
584
|
+
private
|
|
585
|
+
|
|
586
|
+
def normalize_email
|
|
587
|
+
self.email = email.downcase.strip if email
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
user = store.user.new(name: "A", email: "ALICE@EXAMPLE.COM")
|
|
592
|
+
user.valid? # => false
|
|
593
|
+
user.errors[:name] # => ["is too short (minimum is 2 characters)"]
|
|
594
|
+
|
|
595
|
+
user.name = "Alice"
|
|
596
|
+
user.save! # email normalized to "alice@example.com"
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Scopes and Queries
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
record(:post) do
|
|
603
|
+
schema do |t|
|
|
604
|
+
t.string(:title)
|
|
605
|
+
t.boolean(:published, default: false)
|
|
606
|
+
t.timestamps
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
scope :published, -> { where(published: true) }
|
|
610
|
+
scope :recent, -> { order(created_at: :desc).limit(10) }
|
|
611
|
+
|
|
612
|
+
def self.by_title(title)
|
|
613
|
+
where("title LIKE ?", "%#{title}%")
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Use scopes
|
|
618
|
+
published_posts = store.post.published
|
|
619
|
+
recent_posts = store.post.recent
|
|
620
|
+
search_results = store.post.by_title("Ruby")
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### JSON and Array Columns
|
|
624
|
+
|
|
625
|
+
```ruby
|
|
626
|
+
record(:user) do
|
|
627
|
+
schema do |t|
|
|
628
|
+
t.json(:preferences, default: {})
|
|
629
|
+
t.json(:tags, default: [])
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
user = store.user.create!(
|
|
634
|
+
preferences: { theme: "dark", language: "en" },
|
|
635
|
+
tags: ["ruby", "rails"]
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
user.preferences["theme"] # => "dark"
|
|
639
|
+
user.tags # => ["ruby", "rails"]
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
## Testing with VersionedStore
|
|
643
|
+
|
|
644
|
+
### Test Setup
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
require "minitest/autorun"
|
|
648
|
+
require "agent_c"
|
|
649
|
+
|
|
650
|
+
class MyTest < Minitest::Test
|
|
651
|
+
def setup
|
|
652
|
+
@store_class = Class.new(VersionedStore::Base) do
|
|
653
|
+
record(:user) do
|
|
654
|
+
schema do |t|
|
|
655
|
+
t.string(:name)
|
|
656
|
+
t.string(:email)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
@store = @store_class.new(dir: Dir.mktmpdir)
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def test_user_creation
|
|
665
|
+
user = @store.user.create!(name: "Alice", email: "alice@example.com")
|
|
666
|
+
|
|
667
|
+
assert_equal "Alice", user.name
|
|
668
|
+
assert_equal "alice@example.com", user.email
|
|
669
|
+
assert_equal 1, @store.user.count
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### Testing Versioning
|
|
675
|
+
|
|
676
|
+
```ruby
|
|
677
|
+
def test_versioning
|
|
678
|
+
@store.user.transaction { @store.user.create!(name: "Alice") }
|
|
679
|
+
@store.user.transaction { @store.user.create!(name: "Bob") }
|
|
680
|
+
|
|
681
|
+
assert_equal 2, @store.versions.count
|
|
682
|
+
|
|
683
|
+
# Version 0 has only Alice
|
|
684
|
+
assert_equal 1, @store.versions[0].user.count
|
|
685
|
+
assert_equal "Alice", @store.versions[0].user.first.name
|
|
686
|
+
|
|
687
|
+
# Version 1 has both
|
|
688
|
+
assert_equal 2, @store.versions[1].user.count
|
|
689
|
+
end
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
### Testing Restore
|
|
693
|
+
|
|
694
|
+
```ruby
|
|
695
|
+
def test_restore
|
|
696
|
+
user = @store.user.transaction { @store.user.create!(name: "Alice") }
|
|
697
|
+
@store.user.transaction { user.update!(name: "Alice Updated") }
|
|
698
|
+
|
|
699
|
+
# Restore to version 0
|
|
700
|
+
restored_store = @store.versions[0].restore
|
|
701
|
+
|
|
702
|
+
restored_user = restored_store.user.find(user.id)
|
|
703
|
+
assert_equal "Alice", restored_user.name
|
|
704
|
+
end
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
## Common Patterns
|
|
708
|
+
|
|
709
|
+
### Store as a Mixin
|
|
710
|
+
|
|
711
|
+
```ruby
|
|
712
|
+
module MyApp
|
|
713
|
+
module Store
|
|
714
|
+
extend ActiveSupport::Concern
|
|
715
|
+
|
|
716
|
+
included do
|
|
717
|
+
record(:workspace) do
|
|
718
|
+
schema do |t|
|
|
719
|
+
t.string(:dir, null: false)
|
|
720
|
+
t.json(:env, default: {})
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
record(:task) do
|
|
725
|
+
schema do |t|
|
|
726
|
+
t.string(:status, default: "pending")
|
|
727
|
+
t.references(:workspace)
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
belongs_to :workspace, class_name: class_name(:workspace)
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
class MyStore < VersionedStore::Base
|
|
737
|
+
include MyApp::Store
|
|
738
|
+
|
|
739
|
+
record(:user) do
|
|
740
|
+
schema do |t|
|
|
741
|
+
t.string(:name)
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### Polymorphic Associations
|
|
748
|
+
|
|
749
|
+
```ruby
|
|
750
|
+
record(:comment) do
|
|
751
|
+
schema do |t|
|
|
752
|
+
t.text(:body)
|
|
753
|
+
t.string(:commentable_type)
|
|
754
|
+
t.integer(:commentable_id)
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
belongs_to(
|
|
758
|
+
:commentable,
|
|
759
|
+
polymorphic: true
|
|
760
|
+
)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
record(:post) do
|
|
764
|
+
schema do |t|
|
|
765
|
+
t.string(:title)
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
has_many(
|
|
769
|
+
:comments,
|
|
770
|
+
as: :commentable,
|
|
771
|
+
class_name: class_name(:comment)
|
|
772
|
+
)
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# Use polymorphic associations
|
|
776
|
+
post = store.post.create!(title: "Hello")
|
|
777
|
+
comment = store.comment.create!(body: "Great post!", commentable: post)
|
|
778
|
+
|
|
779
|
+
comment.commentable == post # => true
|
|
780
|
+
post.comments.first == comment # => true
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
## Performance Considerations
|
|
784
|
+
|
|
785
|
+
1. **Version Size** - Each version is a full database copy. For large databases, consider:
|
|
786
|
+
- Using `versioned: false` for bulk operations
|
|
787
|
+
- Batch multiple operations in a single transaction
|
|
788
|
+
- Periodically clean old versions
|
|
789
|
+
|
|
790
|
+
2. **Queries** - Standard ActiveRecord performance practices apply:
|
|
791
|
+
- Add indexes for frequently queried columns
|
|
792
|
+
- Use `includes` to avoid N+1 queries
|
|
793
|
+
- Use `find_each` for large result sets
|
|
794
|
+
|
|
795
|
+
3. **Transactions** - Keep transactions small and focused:
|
|
796
|
+
```ruby
|
|
797
|
+
# Good: One logical operation
|
|
798
|
+
store.transaction do
|
|
799
|
+
user = store.user.create!(name: "Alice")
|
|
800
|
+
user.posts.create!(title: "Hello")
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Avoid: Multiple unrelated operations
|
|
804
|
+
store.transaction do
|
|
805
|
+
store.user.create!(name: "Alice")
|
|
806
|
+
store.category.create!(name: "Tech")
|
|
807
|
+
store.setting.update_all(enabled: true)
|
|
808
|
+
end
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
## Reseting the Database
|
|
812
|
+
|
|
813
|
+
You can just delete the directory.
|
|
814
|
+
|
|
815
|
+
## Troubleshooting
|
|
816
|
+
|
|
817
|
+
### Version Directory Growing
|
|
818
|
+
|
|
819
|
+
Old versions are never automatically deleted. Manually clean up:
|
|
820
|
+
|
|
821
|
+
```ruby
|
|
822
|
+
# Remove all but the latest N versions
|
|
823
|
+
def cleanup_versions(store, keep_count = 10)
|
|
824
|
+
version_files = Dir.glob(File.join(store.send(:versions_dir), "*.sqlite3")).sort
|
|
825
|
+
to_delete = version_files[0..-(keep_count + 1)]
|
|
826
|
+
to_delete.each { |f| File.delete(f) }
|
|
827
|
+
end
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
### Migration Not Running
|
|
831
|
+
|
|
832
|
+
Migrations only run on root store initialization:
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
# This runs migrations
|
|
836
|
+
store = MyStore.new( dir: "/tmp/data" )
|
|
837
|
+
|
|
838
|
+
# This doesn't run migrations (accessing existing snapshot)
|
|
839
|
+
version_store = store.versions[0]
|
|
840
|
+
```
|