activerecord-postgresql-branched 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39dd95c063dde3c85a789ac3d6b5a0775bd0c95e39b7e68f2a2c33f9d50b645f
4
- data.tar.gz: 2eaf350fe45a3cbb60aa16c2024c51af3e219ac99f8bc3e02e46fef4c68af2ec
3
+ metadata.gz: 4824fa89d3e5d0b010916b43302ec81d5190f84ba520a48f9bc8f4b629e30d6f
4
+ data.tar.gz: 423dd436e06d97866c3866a7c139ee932501495c603d7ce2a775211d06fb8190
5
5
  SHA512:
6
- metadata.gz: 5b9b1813347f21ce285180b6ac2e9f3a88e1e752ec9521fc615dcd7ee8392310b3ac81c60875cdc4861b6c86db0497c0274d8a405e070e38ae6ac2450ec28592
7
- data.tar.gz: 0fa9c515e10ff4a8cdf21537b5b271198f8b804df8e14fba9ea06afdb830052423b7c014781476fd6600da8188009a89298381aa888ead775ce8bf57aaf6129f
6
+ metadata.gz: 67ff1ed660c4b1c442235624b64b4c5c2b1fed7a632957b3aa64b622a7c1340aa1e3e3f3b03368ec63d508131f4d29f5fca43fe440c277a1c7893e95f1f69c34
7
+ data.tar.gz: b9a96791beaca51e65470d50f0a2bc92991c16f432cc47c437259cbc3ef860171e45b2d0e72de35fdae5c7d10cb4edfacb30795d66f753346cc2435f9223fdd2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Remove primary branch concept — every branch gets its own schema equally
6
+ - Add shadow interception for `add_foreign_key`, `remove_foreign_key`, `add_check_constraint`, `remove_check_constraint`, `validate_foreign_key`, `validate_check_constraint`
7
+ - Add `db:branch:console` rake task — opens psql with the branch `search_path`
8
+ - Simplify branch resolution to `PGBRANCH` env var with git fallback
9
+ - Remove `branch_override` config option and `BRANCH` env var
10
+ - `db:branch:prune` accepts `KEEP=branch1,branch2` for explicit control
11
+ - Fix railtie to reuse the adapter's existing BranchManager
12
+ - Fix redundant Shadow instantiation in migration table shadowing
13
+ - Quote schema identifiers consistently in shadow SQL
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  Initial release.
data/README.md CHANGED
@@ -1,20 +1,16 @@
1
1
  # activerecord-postgresql-branched
2
2
 
3
- Database isolation for parallel development. Each git branch (or agent) gets its own Postgres schema. Migrations run in isolation. Nobody steps on anyone else's work.
4
-
5
- Built for teams running multiple AI coding agents in parallel, each on its own worktree, all sharing one database.
3
+ A Rails database adapter that gives each branch its own PostgreSQL schema. Your database structure follows your branch, just like your code does.
6
4
 
7
5
  ## The problem
8
6
 
9
- You have three agents working simultaneously in three worktrees:
7
+ You're working on a feature branch. You write a migration that adds a column to `users`. You run it. Then you need to switch branches — a review, a hotfix, something urgent.
10
8
 
11
- - `agent-0` adds a `payments` table and alters `users`
12
- - `agent-1` adds a `bio` column to `users`
13
- - `agent-2` drops a legacy table and adds indexes
9
+ The other branch knows nothing about that column. But your development database does. `schema.rb` is dirty. Migrations are out of sync. If the other branch has its own migration, you now have two branches' worth of structural changes in one database with no way to tell which change belongs where.
14
10
 
15
- They share one dev database. Every migration collides. Every agent breaks every other agent. You spend more time untangling the database than reviewing the code.
11
+ You either undo your migration before switching (and redo it when you come back), maintain multiple databases by hand, or just live with the mess. None of these are good.
16
12
 
17
- This adapter makes branching the database as cheap as branching the code.
13
+ Git solved this problem for code decades ago. This adapter solves it for your database.
18
14
 
19
15
  ## Installation
20
16
 
@@ -30,82 +26,67 @@ development:
30
26
  database: myapp_development
31
27
  ```
32
28
 
33
- That's it. No Postgres extensions, no environment variables, no initializers.
29
+ Set the `PGBRANCH` environment variable to name your branch, or let the adapter detect it from git automatically:
30
+
31
+ ```bash
32
+ export PGBRANCH=feature/payments
33
+ ```
34
+
35
+ That's it. No PostgreSQL extensions, no initializers, no extra configuration.
34
36
 
35
37
  ## How it works
36
38
 
37
- On connection, the adapter reads the current git branch, creates a dedicated Postgres schema for it, and sets `search_path`:
39
+ On connection, the adapter creates a dedicated PostgreSQL schema for the current branch and sets `search_path`:
38
40
 
39
41
  ```
40
- git branch: feature/payments
42
+ PGBRANCH=feature/payments
43
+
41
44
  schema: branch_feature_payments
42
45
  search_path: branch_feature_payments, public
43
46
  ```
44
47
 
45
- New tables go into the branch schema. Queries against existing tables fall through to `public` via standard Postgres name resolution.
48
+ New tables go into the branch schema. Queries against tables that don't exist in the branch schema fall through to `public` via standard PostgreSQL name resolution. No data copying for reads.
46
49
 
47
- When a migration modifies a table that exists in `public`, the adapter shadows it first -- copies the table and its data into the branch schema, then applies the DDL to the copy. The public table is never touched.
50
+ ### The shadow rule
48
51
 
49
- On the primary branch (`main` by default), the adapter stands aside entirely. Migrations land in `public` as normal. When `public` advances, every branch sees the changes immediately via `search_path` fallthrough.
52
+ When a migration modifies a table that exists in `public` but not yet in the branch schema, the adapter **shadows** it first copies the table structure and data into the branch schema, then applies the DDL to the copy:
50
53
 
51
- ## Agentic workflows
52
-
53
- Give each agent its own branch identity:
54
-
55
- ```yaml
56
- # config/database.yml
57
- development:
58
- adapter: postgresql_branched
59
- database: myapp_development
60
- branch_override: <%= ENV.fetch("AGENT_BRANCH", nil) %>
61
54
  ```
55
+ add_column :users, :bio, :string
62
56
 
63
- ```bash
64
- # Launch agents with isolated schemas
65
- AGENT_BRANCH=agent-0 bundle exec rails ...
66
- AGENT_BRANCH=agent-1 bundle exec rails ...
67
- AGENT_BRANCH=agent-2 bundle exec rails ...
57
+ 1. CREATE TABLE branch_feature_payments.users (LIKE public.users INCLUDING ALL)
58
+ 2. INSERT INTO branch_feature_payments.users SELECT * FROM public.users
59
+ 3. ALTER TABLE users ADD COLUMN bio VARCHAR
68
60
  ```
69
61
 
70
- Or in a worktree-per-agent setup, each worktree is on its own git branch and the adapter picks it up automatically. No configuration needed beyond the adapter name.
62
+ The public table is never touched. Step 3 operates on the shadow because `search_path` resolves `users` to the branch schema first.
71
63
 
72
- ### Cleanup
64
+ This applies to any DDL that modifies an existing table: `add_column`, `remove_column`, `add_index`, `add_foreign_key`, `drop_table`, and so on.
73
65
 
74
- After agents finish:
66
+ ### schema.rb stays clean
75
67
 
76
- ```bash
77
- rails db:branch:prune
78
- ```
68
+ `db:schema:dump` presents a unified view as if everything lived in `public`:
79
69
 
80
- Compares branch schemas against `git branch --list` and drops any that no longer have a corresponding local branch. One command, all stale schemas gone.
70
+ - Branch-local tables appear without schema prefixes
71
+ - Shadowed tables show the branch version (with new columns, dropped indexes, etc.)
72
+ - Public tables the branch hasn't touched are included as normal
81
73
 
82
- For specific branches:
74
+ The diff for a schema change looks exactly as it always has. Switch branches, and `schema.rb` reflects that branch's database state — not the accumulated mess of every migration you've run lately.
83
75
 
84
- ```bash
85
- rails db:branch:discard BRANCH=agent-0
86
- ```
76
+ ## Switching branches
87
77
 
88
- ## Rake tasks
78
+ This is the whole point. When you switch git branches:
89
79
 
90
- ```bash
91
- rails db:branch:reset # drop and recreate current branch schema
92
- rails db:branch:discard # drop current branch schema (or BRANCH=name)
93
- rails db:branch:list # list all branch schemas and their sizes
94
- rails db:branch:diff # show objects in this branch vs public
95
- rails db:branch:prune # drop schemas for branches no longer in git
96
- ```
80
+ - The new branch gets its own schema (created on first connection if needed)
81
+ - Its migrations are tracked independently
82
+ - Tables it hasn't touched fall through to `public`
83
+ - Tables it has modified are isolated in its own schema
97
84
 
98
- ## Configuration
85
+ Switch back, and your previous branch's state is exactly where you left it.
99
86
 
100
- ```yaml
101
- development:
102
- adapter: postgresql_branched
103
- database: myapp_development
104
- primary_branch: main # default, can be 'master', 'trunk', etc.
105
- branch_override: agent-0 # bypass git, set branch explicitly
106
- ```
87
+ ## Deploying
107
88
 
108
- The `PGBRANCH` or `BRANCH` environment variables also work for explicit branch selection.
89
+ The adapter is for development and test only. Production and staging use the standard `postgresql` adapter migrations land directly in the database as they always have. The canonical schema advances through your normal deployment process.
109
90
 
110
91
  ## Rebasing
111
92
 
@@ -115,35 +96,55 @@ rails db:branch:reset
115
96
  rails db:migrate
116
97
  ```
117
98
 
118
- `db:branch:reset` drops the branch schema. `search_path` falls through to the updated `public`. Re-running `db:migrate` reapplies your branch's migrations on top of the new baseline.
99
+ `db:branch:reset` drops the branch schema. Queries fall through to `public`. Re-running `db:migrate` reapplies your branch's migrations on top of the current baseline.
119
100
 
120
- ## The merge story
101
+ ## Parallel agents
121
102
 
122
- The adapter does not merge. Git does.
103
+ The same isolation that helps you switch branches also helps when running multiple AI agents in parallel, each on its own worktree:
123
104
 
124
- 1. Agent writes migrations on its branch
125
- 2. Adapter isolates them in a branch schema automatically
126
- 3. PR merged into `main`
127
- 4. Team pulls `main`, runs `db:migrate`
128
- 5. Adapter stands aside (primary branch), migrations land in `public`
129
- 6. `schema.rb` updated and committed
130
- 7. All active branch schemas see updated `public` via fallthrough
105
+ ```bash
106
+ PGBRANCH=agent-0 bundle exec rails ...
107
+ PGBRANCH=agent-1 bundle exec rails ...
108
+ PGBRANCH=agent-2 bundle exec rails ...
109
+ ```
131
110
 
132
- ## schema.rb
111
+ Each agent gets full isolation. No locks, no coordination. When an agent's work is done:
133
112
 
134
- `db:schema:dump` presents a unified view of the current branch as if everything lived in `public`:
113
+ ```bash
114
+ rails db:branch:discard BRANCH=agent-0
115
+ ```
135
116
 
136
- - Branch-local tables appear without the `branch_` prefix
137
- - Shadowed tables show the branch version
138
- - Public tables the branch hasn't touched are included as normal
139
- - No schema references, no branch artifacts
117
+ ## Rake tasks
118
+
119
+ ```bash
120
+ rails db:branch:reset # drop and recreate current branch schema
121
+ rails db:branch:discard # drop current branch schema (or BRANCH=name)
122
+ rails db:branch:list # list all branch schemas and their sizes
123
+ rails db:branch:diff # show tables in the current branch schema
124
+ rails db:branch:prune # drop stale schemas (KEEP=main,feature/x or auto-detect from git)
125
+ rails db:branch:console # open psql with the branch search_path
126
+ ```
127
+
128
+ ### psql
129
+
130
+ `psql` connects directly to PostgreSQL and knows nothing about branch schemas. Use `db:branch:console` instead — it launches psql with `search_path` set to your branch schema, so you see exactly what your Rails app sees.
131
+
132
+ ## Configuration
133
+
134
+ The adapter needs one thing: a branch name. Resolution order:
135
+
136
+ 1. `PGBRANCH` environment variable
137
+ 2. `git branch --show-current` (automatic fallback)
138
+
139
+ If neither is available, the adapter raises an error.
140
+
141
+ All standard PostgreSQL connection parameters work as normal (`host`, `port`, `username`, `password`, etc.).
140
142
 
141
- The diff for a schema change looks exactly as it always has.
143
+ **Do not set `schema_search_path` in database.yml** it conflicts with the adapter's `search_path` management.
142
144
 
143
145
  ## Limitations
144
146
 
145
- - **Rails + Postgres only** -- uses Postgres schemas and `search_path`
146
- - **Dev only** -- production and staging should use the standard `postgresql` adapter
147
- - **No `schema_search_path` in database.yml** -- it will conflict with the adapter
148
- - **Non-ActiveRecord DDL** -- raw SQL outside of migrations bypasses the shadow rule
149
- - **Sequences on shadowed tables** -- `rename_table` on a shadowed table with serial columns works, but the sequence keeps its original name
147
+ - **Rails + PostgreSQL only** uses PostgreSQL schemas and `search_path`
148
+ - **Development and test only** production should use the standard `postgresql` adapter
149
+ - **Non-ActiveRecord DDL** raw SQL outside of migrations bypasses the shadow rule
150
+ - **Sequences on shadowed tables** `rename_table` on a shadowed table with serial columns works, but the sequence keeps its original name
@@ -17,6 +17,12 @@ module ActiveRecord
17
17
  add_index
18
18
  remove_index
19
19
  rename_index
20
+ add_foreign_key
21
+ remove_foreign_key
22
+ add_check_constraint
23
+ remove_check_constraint
24
+ validate_foreign_key
25
+ validate_check_constraint
20
26
  drop_table
21
27
  change_table
22
28
  bulk_change_table
@@ -25,17 +31,17 @@ module ActiveRecord
25
31
  def initialize(...)
26
32
  super
27
33
  @branch_manager = BranchManager.new(self, @config)
28
- @shadow = Shadow.new(self, @branch_manager.branch_schema) unless @branch_manager.primary_branch?
34
+ @shadow = Shadow.new(self, @branch_manager.branch_schema)
29
35
  end
30
36
 
31
37
  def configure_connection
32
38
  super
33
- @branch_manager.activate
39
+ @branch_manager.activate(@shadow)
34
40
  end
35
41
 
36
42
  SHADOW_BEFORE.each do |method|
37
43
  define_method(method) do |table_name, *args, **kwargs, &block|
38
- @shadow&.call(table_name)
44
+ @shadow.call(table_name)
39
45
  super(table_name, *args, **kwargs, &block)
40
46
  end
41
47
  end
@@ -45,10 +51,9 @@ module ActiveRecord
45
51
  # the branch schema. The table and index renames succeed before the
46
52
  # sequence rename fails, so we rescue the sequence error.
47
53
  def rename_table(table_name, new_name, **options)
48
- @shadow&.call(table_name)
54
+ @shadow.call(table_name)
49
55
  super
50
56
  rescue ActiveRecord::StatementInvalid => e
51
- raise if @branch_manager.primary_branch?
52
57
  raise unless e.cause.is_a?(PG::UndefinedTable)
53
58
  end
54
59
 
@@ -12,16 +12,10 @@ module ActiveRecord
12
12
  @branch_schema = self.class.sanitise(@branch)
13
13
  end
14
14
 
15
- def activate
16
- return if primary_branch?
17
-
15
+ def activate(shadow)
18
16
  ensure_schema
19
17
  set_search_path
20
- shadow_migration_tables
21
- end
22
-
23
- def primary_branch?
24
- @branch == primary_branch_name
18
+ shadow_migration_tables(shadow)
25
19
  end
26
20
 
27
21
  def reset
@@ -32,11 +26,6 @@ module ActiveRecord
32
26
 
33
27
  def discard(branch_name = @branch)
34
28
  schema = self.class.sanitise(branch_name)
35
-
36
- if schema == self.class.sanitise(primary_branch_name)
37
- raise "Cannot discard the primary branch schema"
38
- end
39
-
40
29
  @connection.execute("DROP SCHEMA IF EXISTS #{quote(schema)} CASCADE")
41
30
  end
42
31
 
@@ -56,8 +45,6 @@ module ActiveRecord
56
45
  end
57
46
 
58
47
  def diff
59
- return [] if primary_branch?
60
-
61
48
  @connection.select_values(<<~SQL)
62
49
  SELECT table_name FROM information_schema.tables
63
50
  WHERE table_schema = #{@connection.quote(@branch_schema)}
@@ -75,14 +62,13 @@ module ActiveRecord
75
62
 
76
63
  return schema if schema.bytesize <= MAX_SCHEMA_LENGTH
77
64
 
78
- # Truncate and append a short hash to avoid collisions
79
65
  hash = Digest::SHA256.hexdigest(slug)[0, 8]
80
- max_slug = MAX_SCHEMA_LENGTH - PREFIX.bytesize - 9 # 9 = underscore + 8 char hash
66
+ max_slug = MAX_SCHEMA_LENGTH - PREFIX.bytesize - 9
81
67
  PREFIX + slug[0, max_slug] + "_" + hash
82
68
  end
83
69
 
84
70
  def prune(keep: nil)
85
- active_schemas = if keep
71
+ keep_schemas = if keep
86
72
  Array(keep).map { |b| self.class.sanitise(b) }.to_set
87
73
  else
88
74
  git_branches = `git branch --list 2>/dev/null`.lines.map { |l| l.strip.delete_prefix("* ") }
@@ -97,37 +83,30 @@ module ActiveRecord
97
83
  WHERE schema_name LIKE 'branch_%'
98
84
  SQL
99
85
 
100
- stale = all_branch_schemas.reject { |s| active_schemas.include?(s) }
86
+ stale = all_branch_schemas.reject { |s| keep_schemas.include?(s) }
101
87
  stale.each do |schema|
102
88
  @connection.execute("DROP SCHEMA IF EXISTS #{quote(schema)} CASCADE")
103
89
  end
104
90
  stale
105
91
  end
106
92
 
107
- def self.resolve_branch_name(config)
108
- config[:branch_override]&.to_s ||
109
- ENV["BRANCH"] ||
110
- ENV["PGBRANCH"] ||
111
- git_branch
93
+ def self.resolve_branch_name
94
+ ENV["PGBRANCH"] || git_branch
112
95
  end
113
96
 
114
97
  private
115
98
 
116
99
  def resolve_branch
117
- name = self.class.resolve_branch_name(@config)
100
+ name = self.class.resolve_branch_name
118
101
 
119
102
  if name.nil? || name.empty?
120
- raise "Could not determine git branch. " \
121
- "Set branch_override in database.yml or the PGBRANCH environment variable."
103
+ raise "Could not determine branch. " \
104
+ "Set the PGBRANCH environment variable."
122
105
  end
123
106
 
124
107
  name
125
108
  end
126
109
 
127
- def primary_branch_name
128
- (@config[:primary_branch] || "main").to_s
129
- end
130
-
131
110
  def ensure_schema
132
111
  @connection.execute("CREATE SCHEMA IF NOT EXISTS #{quote(@branch_schema)}")
133
112
  end
@@ -140,8 +119,7 @@ module ActiveRecord
140
119
  @connection.schema_search_path = "#{@branch_schema}, public"
141
120
  end
142
121
 
143
- def shadow_migration_tables
144
- shadow = Shadow.new(@connection, @branch_schema)
122
+ def shadow_migration_tables(shadow)
145
123
  shadow.call(ActiveRecord::Base.schema_migrations_table_name)
146
124
  shadow.call(ActiveRecord::Base.internal_metadata_table_name)
147
125
  end
@@ -9,17 +9,11 @@ module ActiveRecord
9
9
  desc "Drop and recreate the current branch schema"
10
10
  task reset: :load_config do
11
11
  manager = branch_manager
12
-
13
- if manager.primary_branch?
14
- puts "On primary branch (#{manager.branch}), nothing to reset."
15
- next
16
- end
17
-
18
12
  manager.reset
19
13
  puts "Reset branch schema #{manager.branch_schema}. Run db:migrate to reapply branch migrations."
20
14
  end
21
15
 
22
- desc "Drop the current branch schema entirely"
16
+ desc "Drop a branch schema (current branch or BRANCH=name)"
23
17
  task discard: :load_config do
24
18
  manager = branch_manager
25
19
  branch = ENV["BRANCH"] || manager.branch
@@ -43,9 +37,10 @@ module ActiveRecord
43
37
  end
44
38
  end
45
39
 
46
- desc "Drop schemas for branches that no longer exist in git"
40
+ desc "Drop schemas not in KEEP list (or not matching local git branches)"
47
41
  task prune: :load_config do
48
- pruned = branch_manager.prune
42
+ keep = ENV["KEEP"]&.split(",")&.map(&:strip)
43
+ pruned = branch_manager.prune(keep: keep)
49
44
 
50
45
  if pruned.empty?
51
46
  puts "No stale branch schemas found."
@@ -55,15 +50,9 @@ module ActiveRecord
55
50
  end
56
51
  end
57
52
 
58
- desc "Show objects in the current branch schema vs public"
53
+ desc "Show objects in the current branch schema"
59
54
  task diff: :load_config do
60
55
  manager = branch_manager
61
-
62
- if manager.primary_branch?
63
- puts "On primary branch, no diff."
64
- next
65
- end
66
-
67
56
  tables = manager.diff
68
57
 
69
58
  if tables.empty?
@@ -73,12 +62,27 @@ module ActiveRecord
73
62
  tables.each { |t| puts " #{t}" }
74
63
  end
75
64
  end
65
+
66
+ desc "Open psql with the branch search_path"
67
+ task console: :load_config do
68
+ manager = branch_manager
69
+ config = ActiveRecord::Base.connection_db_config.configuration_hash
70
+
71
+ env = { "PGOPTIONS" => "-c search_path=#{manager.branch_schema},public" }
72
+ args = ["psql"]
73
+ args.push("-h", config[:host].to_s) if config[:host]
74
+ args.push("-p", config[:port].to_s) if config[:port]
75
+ args.push("-U", config[:username].to_s) if config[:username]
76
+ args.push(config[:database].to_s)
77
+
78
+ puts "Connecting to #{config[:database]} as #{manager.branch_schema}..."
79
+ exec(env, *args)
80
+ end
76
81
  end
77
82
  end
78
83
 
79
84
  def branch_manager
80
- connection = ActiveRecord::Base.lease_connection
81
- BranchManager.new(connection, connection.instance_variable_get(:@config))
85
+ ActiveRecord::Base.lease_connection.branch_manager
82
86
  end
83
87
  end
84
88
 
@@ -8,9 +8,7 @@ module ActiveRecord
8
8
  private
9
9
 
10
10
  def on_branch?
11
- @connection.respond_to?(:branch_manager) &&
12
- @connection.branch_manager &&
13
- !@connection.branch_manager.primary_branch?
11
+ @connection.respond_to?(:branch_manager)
14
12
  end
15
13
 
16
14
  def initialize(connection, options = {})
@@ -34,13 +34,14 @@ module ActiveRecord
34
34
  end
35
35
 
36
36
  def create_shadow(table)
37
+ quoted_branch = @connection.quote_column_name(@branch_schema)
37
38
  quoted_table = @connection.quote_column_name(table)
38
39
  @connection.execute(<<~SQL)
39
- CREATE TABLE #{@branch_schema}.#{quoted_table}
40
+ CREATE TABLE #{quoted_branch}.#{quoted_table}
40
41
  (LIKE public.#{quoted_table} INCLUDING ALL)
41
42
  SQL
42
43
  @connection.execute(<<~SQL)
43
- INSERT INTO #{@branch_schema}.#{quoted_table}
44
+ INSERT INTO #{quoted_branch}.#{quoted_table}
44
45
  SELECT * FROM public.#{quoted_table}
45
46
  SQL
46
47
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-postgresql-branched
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Dawson