1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
//! Schema creation and in-place migrations.
//!
//! Split out of `src/storage/sqlite/mod.rs` (bobbin-aoz). Rust lets one
//! type's inherent methods live in several `impl` blocks across modules, so
//! this is a real split rather than a re-export shim.
use anyhow::Result;
use super::MetadataStore;
impl MetadataStore {
/// Initialize the database schema
pub(super) fn init_schema(&self) -> Result<()> {
self.conn.execute_batch(
r#"
-- Temporal coupling (git co-change relationships)
CREATE TABLE IF NOT EXISTS coupling (
file_a TEXT NOT NULL,
file_b TEXT NOT NULL,
score REAL,
co_changes INTEGER,
last_co_change INTEGER,
PRIMARY KEY (file_a, file_b)
);
-- Cross-repo coupling (bo-oqny). Co-change inferred across repos via
-- shared bead references; both sides carry their repo because paths
-- are repo-relative and collide across repos. Additive: leaves the
-- per-repo `coupling` table above untouched. Canonicalized so
-- (repo_a, path_a) <= (repo_b, path_b).
CREATE TABLE IF NOT EXISTS cross_repo_coupling (
repo_a TEXT NOT NULL,
path_a TEXT NOT NULL,
repo_b TEXT NOT NULL,
path_b TEXT NOT NULL,
score REAL,
co_changes INTEGER,
last_co_change INTEGER,
PRIMARY KEY (repo_a, path_a, repo_b, path_b)
);
-- Global metadata
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- File hash tracking for incremental indexing. Keyed by
-- (repo, file_path): the bare path is NOT unique across a
-- multi-repo index (19 repos each have a README.md), and a
-- path-only key let the first writer own the row while every
-- other repo's copy was judged against the wrong hash and
-- silently skipped forever.
CREATE TABLE IF NOT EXISTS file_hashes (
repo TEXT NOT NULL,
file_path TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (repo, file_path)
);
-- Bead → bundle → commit workflow telemetry (GH#9, Layer 1: logging).
-- Each row records that a bead was linked to a commit / changeset, so
-- later layers can mine which files matter for which kinds of work.
CREATE TABLE IF NOT EXISTS bead_lineage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
bead_id TEXT NOT NULL,
bead_type TEXT,
commit_sha TEXT,
bundle_slugs TEXT,
touched_files TEXT,
action_type TEXT
);
-- Bug causality (GH#9 telemetry Phase 0, bo-s1kb). The supervised
-- signal for "risky change": reconstructs which prior commit most
-- likely introduced the bug a later bead fixed, per file. One row per
-- (bug, culprit_sha, file); UNIQUE makes the reconstruction job
-- idempotent so periodic re-runs upsert rather than duplicate.
CREATE TABLE IF NOT EXISTS bug_causality (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
bug_id TEXT NOT NULL,
culprit_sha TEXT,
culprit_bead_id TEXT,
file TEXT,
confidence REAL,
UNIQUE(bug_id, culprit_sha, file)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_coupling_score ON coupling(score DESC);
CREATE INDEX IF NOT EXISTS idx_xrepo_coupling_a ON cross_repo_coupling(repo_a, path_a);
CREATE INDEX IF NOT EXISTS idx_xrepo_coupling_b ON cross_repo_coupling(repo_b, path_b);
CREATE INDEX IF NOT EXISTS idx_bead_lineage_bead ON bead_lineage(bead_id);
CREATE INDEX IF NOT EXISTS idx_bead_lineage_commit ON bead_lineage(commit_sha);
CREATE INDEX IF NOT EXISTS idx_bug_causality_bug ON bug_causality(bug_id);
"#,
)?;
self.migrate_bead_lineage()?;
self.migrate_repo_scoped_state()?;
Ok(())
}
/// One-time migration to repo-scoped incremental state.
///
/// The pre-migration `file_hashes` rows are keyed by bare path, so in a
/// multi-repo index each row belongs to whichever repo wrote it first and
/// there is no way to attribute it after the fact — re-keying would just
/// bless the collided data. The rows are dropped instead; the next index
/// run per repo re-hashes (and re-embeds changed files) from scratch, which
/// is the one-time rebuild this fix requires.
///
/// The legacy GLOBAL watermark keys (`last_indexed_commit`,
/// `last_coupling_commit`, `coupling_depth`) are deleted for the same
/// reason: one SHA shared by every repo means each repo was asked for
/// "commits since" a commit it does not contain. Their per-repo
/// replacements (`last_indexed_commit:<repo>`, …) are written fresh by the
/// next index run; a missing watermark falls back to the full-depth scan a
/// fresh index already does.
pub(super) fn migrate_repo_scoped_state(&self) -> Result<()> {
// Old schema is detected by the absence of the `repo` column. The DROP
// runs at most once: the recreate below uses the new schema.
let mut has_repo = false;
let mut has_table = false;
{
let mut stmt = self.conn.prepare("PRAGMA table_info(file_hashes)")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
for r in rows {
has_table = true;
if r? == "repo" {
has_repo = true;
}
}
}
if has_table && !has_repo {
self.conn.execute_batch(
r#"
DROP TABLE file_hashes;
CREATE TABLE file_hashes (
repo TEXT NOT NULL,
file_path TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (repo, file_path)
);
"#,
)?;
}
// Idempotent; a no-op once the keys are gone.
self.conn.execute(
"DELETE FROM meta WHERE key IN ('last_indexed_commit', 'last_coupling_commit', 'coupling_depth')",
[],
)?;
Ok(())
}
/// Idempotently add columns introduced after the initial bead_lineage schema
/// (telemetry Phase 0, bo-xrsy). SQLite has no `ADD COLUMN IF NOT EXISTS`, so
/// we inspect `PRAGMA table_info` and only ALTER for genuinely-missing
/// columns. Errors other than the additions themselves propagate — we do not
/// blind-try-and-ignore. `bundle_slugs` already exists in the base schema and
/// is intentionally absent here (this migration only adds new columns).
pub(super) fn migrate_bead_lineage(&self) -> Result<()> {
let mut existing = std::collections::HashSet::new();
{
let mut stmt = self.conn.prepare("PRAGMA table_info(bead_lineage)")?;
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
for r in rows {
existing.insert(r?);
}
}
// (column, SQL type) — additive only.
let additions = [
("feature_id", "TEXT"),
("lines_added", "INTEGER"),
("lines_deleted", "INTEGER"),
("touched_symbols", "TEXT"),
];
for (col, ty) in additions {
if !existing.contains(col) {
self.conn.execute(
&format!("ALTER TABLE bead_lineage ADD COLUMN {} {}", col, ty),
[],
)?;
}
}
Ok(())
}
}