Skip to content

Commit f54177d

Browse files
tlmaloneyTom Maloneyclaude
authored
fix(pg): auto-migrate schema on pg serve startup (#252)
## Summary - Run `EnsureSchema` before `CheckSchemaCompat` in `pg serve` so missing tables (e.g. `tool_result_events` after upgrading from 0.16.x to 0.17.0) are created automatically - Read-only PG roles gracefully fall through to the existing compat check error - Add `TestEnsureSchemaMigratesLegacySchema` integration test that simulates a legacy schema upgrade Closes #251 ## Test plan - [ ] `make test` passes - [ ] `make test-postgres` passes (exercises `TestEnsureSchemaMigratesLegacySchema`) - [ ] Manual: upgrade from 0.16.x schema, verify `pg serve` starts without manual SQL 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Tom Maloney <tom@supermaloney.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f14669c commit f54177d

File tree

2 files changed

+108
-0
lines changed

2 files changed

+108
-0
lines changed

cmd/agentsview/pg.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,20 @@ func runPGServe(args []string) {
235235
)
236236
defer stop()
237237

238+
// Attempt to apply any missing schema migrations before
239+
// the compatibility check. This handles upgrades (e.g.
240+
// new tables like tool_result_events) without requiring a
241+
// manual schema drop. If the PG role is read-only the
242+
// migration is skipped and the compat check reports what
243+
// is missing.
244+
if err := postgres.EnsureSchema(
245+
ctx, store.DB(), pgCfg.Schema,
246+
); err != nil {
247+
if !postgres.IsReadOnlyError(err) {
248+
fatal("pg serve: schema migration failed: %v", err)
249+
}
250+
}
251+
238252
if err := postgres.CheckSchemaCompat(
239253
ctx, store.DB(),
240254
); err != nil {

internal/postgres/sync_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,100 @@ func TestEnsureSchemaIdempotent(t *testing.T) {
6969
}
7070
}
7171

72+
func TestEnsureSchemaMigratesLegacySchema(t *testing.T) {
73+
pgURL := testPGURL(t)
74+
cleanPGSchema(t, pgURL)
75+
t.Cleanup(func() { cleanPGSchema(t, pgURL) })
76+
77+
pg, err := Open(pgURL, "agentsview", true)
78+
if err != nil {
79+
t.Fatalf("connecting to pg: %v", err)
80+
}
81+
defer pg.Close()
82+
83+
ctx := context.Background()
84+
85+
// Simulate a 0.16.x schema: create the schema and core
86+
// tables but omit tool_result_events.
87+
if _, err := pg.ExecContext(ctx,
88+
"CREATE SCHEMA IF NOT EXISTS agentsview",
89+
); err != nil {
90+
t.Fatalf("creating schema: %v", err)
91+
}
92+
legacyDDL := `
93+
CREATE TABLE IF NOT EXISTS sync_metadata (
94+
key TEXT PRIMARY KEY,
95+
value TEXT NOT NULL
96+
);
97+
CREATE TABLE IF NOT EXISTS sessions (
98+
id TEXT PRIMARY KEY,
99+
machine TEXT NOT NULL,
100+
project TEXT NOT NULL,
101+
agent TEXT NOT NULL,
102+
first_message TEXT,
103+
display_name TEXT,
104+
created_at TIMESTAMPTZ,
105+
started_at TIMESTAMPTZ,
106+
ended_at TIMESTAMPTZ,
107+
deleted_at TIMESTAMPTZ,
108+
message_count INT NOT NULL DEFAULT 0,
109+
user_message_count INT NOT NULL DEFAULT 0,
110+
parent_session_id TEXT,
111+
relationship_type TEXT NOT NULL DEFAULT '',
112+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
113+
);
114+
CREATE TABLE IF NOT EXISTS messages (
115+
session_id TEXT NOT NULL,
116+
ordinal INT NOT NULL,
117+
role TEXT NOT NULL,
118+
content TEXT NOT NULL,
119+
timestamp TIMESTAMPTZ,
120+
has_thinking BOOLEAN NOT NULL DEFAULT FALSE,
121+
has_tool_use BOOLEAN NOT NULL DEFAULT FALSE,
122+
content_length INT NOT NULL DEFAULT 0,
123+
is_system BOOLEAN NOT NULL DEFAULT FALSE,
124+
PRIMARY KEY (session_id, ordinal),
125+
FOREIGN KEY (session_id)
126+
REFERENCES sessions(id) ON DELETE CASCADE
127+
);
128+
CREATE TABLE IF NOT EXISTS tool_calls (
129+
id BIGSERIAL PRIMARY KEY,
130+
session_id TEXT NOT NULL,
131+
tool_name TEXT NOT NULL,
132+
category TEXT NOT NULL,
133+
call_index INT NOT NULL DEFAULT 0,
134+
tool_use_id TEXT NOT NULL DEFAULT '',
135+
input_json TEXT,
136+
skill_name TEXT,
137+
result_content_length INT,
138+
result_content TEXT,
139+
subagent_session_id TEXT,
140+
message_ordinal INT NOT NULL,
141+
FOREIGN KEY (session_id)
142+
REFERENCES sessions(id) ON DELETE CASCADE
143+
);`
144+
if _, err := pg.ExecContext(ctx, legacyDDL); err != nil {
145+
t.Fatalf("creating legacy tables: %v", err)
146+
}
147+
148+
// Verify tool_result_events does not exist yet.
149+
if err := CheckSchemaCompat(ctx, pg); err == nil {
150+
t.Fatal("expected CheckSchemaCompat to fail on legacy schema")
151+
}
152+
153+
// Run EnsureSchema — should create the missing table.
154+
if err := EnsureSchema(ctx, pg, "agentsview"); err != nil {
155+
t.Fatalf("EnsureSchema on legacy schema: %v", err)
156+
}
157+
158+
// Now the compat check should pass.
159+
if err := CheckSchemaCompat(ctx, pg); err != nil {
160+
t.Fatalf(
161+
"CheckSchemaCompat after migration: %v", err,
162+
)
163+
}
164+
}
165+
72166
func TestPushSingleSession(t *testing.T) {
73167
pgURL := testPGURL(t)
74168
cleanPGSchema(t, pgURL)

0 commit comments

Comments
 (0)