codepulse-engine 0.1.0__py3-none-any.whl
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.
- codepulse/__init__.py +5 -0
- codepulse/api/app.py +1336 -0
- codepulse/api/errors.py +172 -0
- codepulse/api/security.py +189 -0
- codepulse/auth.py +406 -0
- codepulse/cli.py +1052 -0
- codepulse/config.py +475 -0
- codepulse/data_location.py +78 -0
- codepulse/embeddings/chunker.py +138 -0
- codepulse/embeddings/embedder.py +120 -0
- codepulse/git_diff.py +201 -0
- codepulse/indexer/engine.py +2655 -0
- codepulse/indexer/ignore.py +120 -0
- codepulse/ingestion_safety.py +169 -0
- codepulse/jobs.py +257 -0
- codepulse/mcp_server.py +269 -0
- codepulse/models.py +140 -0
- codepulse/observability.py +130 -0
- codepulse/parser/__init__.py +8 -0
- codepulse/parser/base.py +58 -0
- codepulse/parser/factory.py +37 -0
- codepulse/parser/graph.py +502 -0
- codepulse/parser/javascript.py +428 -0
- codepulse/parser/module_graph.py +232 -0
- codepulse/parser/python.py +331 -0
- codepulse/rate_limit.py +142 -0
- codepulse/repository_identity.py +83 -0
- codepulse/search/bm25.py +342 -0
- codepulse/search/ranker.py +382 -0
- codepulse/search/tokenizer.py +80 -0
- codepulse/search/vector.py +357 -0
- codepulse/storage/db.py +526 -0
- codepulse/storage/migrations.py +347 -0
- codepulse/storage/schema_migrations/README +1 -0
- codepulse/storage/schema_migrations/env.py +70 -0
- codepulse/storage/schema_migrations/script.py.mako +28 -0
- codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
- codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
- codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
- codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
- codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
- codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
- codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
- codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
- codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
- codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
- codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
- codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
- codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
- codepulse/symbol_identity.py +29 -0
- codepulse_engine-0.1.0.dist-info/METADATA +191 -0
- codepulse_engine-0.1.0.dist-info/RECORD +56 -0
- codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
- codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
- codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Schema migrations: the Alembic plumbing, plus the safe `codepulse migrate` logic
|
|
3
|
+
(CODEPULSE_FUTURE_CHANGES.md items 8 and 17 and finding K; user-approved 2026-10-14).
|
|
4
|
+
|
|
5
|
+
The migration scripts live INSIDE the package (`codepulse/storage/schema_migrations/`)
|
|
6
|
+
so they ship in a wheel, and the Alembic config is built in code instead of read from a
|
|
7
|
+
repository-root `alembic.ini`, so none of this depends on the checkout or the current
|
|
8
|
+
directory. (`alembic.ini` still exists for developers running `alembic upgrade head`
|
|
9
|
+
from the repo root; it points at the same scripts.) Building the config without an
|
|
10
|
+
ini file also means a migration run no longer applies the ini's logging section, which
|
|
11
|
+
used to disable every logger that already existed and reset the root handlers.
|
|
12
|
+
|
|
13
|
+
A database can be in one of these states (`get_schema_status`):
|
|
14
|
+
fresh no tables yet (or no file): nothing to upgrade, `migrate` creates it at head
|
|
15
|
+
current tracked by Alembic and at head
|
|
16
|
+
behind tracked, at an older revision this CodePulse knows: `migrate` upgrades it
|
|
17
|
+
unstamped has tables but was never tracked (created by `create_all`, as every
|
|
18
|
+
database CodePulse made before this change) AND its schema matches the
|
|
19
|
+
models, apart from tables it simply does not have yet (a database made
|
|
20
|
+
before a table existed, not opened by newer code since): `migrate`
|
|
21
|
+
creates those empty tables and stamps it at head
|
|
22
|
+
diverged unstamped and its schema DIFFERS from the models in any other way (a
|
|
23
|
+
missing or extra column, an extra table -- e.g. a legacy table that a later
|
|
24
|
+
migration renames): stamping it would claim a schema it does not have, so
|
|
25
|
+
`migrate` refuses and says what differs
|
|
26
|
+
unknown tracked, but at a revision this CodePulse does not have (a database from
|
|
27
|
+
a newer CodePulse): `migrate` refuses
|
|
28
|
+
|
|
29
|
+
`migrate_database` never changes a database it refuses, takes a consistent backup of a
|
|
30
|
+
SQLite file (the sqlite3 backup API, safe with a writer present) before it alters one,
|
|
31
|
+
and verifies the result. Looking at a database (`get_schema_status`, `check=True`) never
|
|
32
|
+
creates or changes it.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import sqlite3
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from functools import lru_cache
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Optional
|
|
42
|
+
|
|
43
|
+
from alembic import command
|
|
44
|
+
from alembic.autogenerate import compare_metadata
|
|
45
|
+
from alembic.config import Config
|
|
46
|
+
from alembic.runtime.migration import MigrationContext
|
|
47
|
+
from alembic.script import ScriptDirectory
|
|
48
|
+
from sqlalchemy import create_engine, inspect, text
|
|
49
|
+
from sqlalchemy.engine import Engine, make_url
|
|
50
|
+
from sqlalchemy.exc import IntegrityError
|
|
51
|
+
|
|
52
|
+
from codepulse.config import get_settings
|
|
53
|
+
from codepulse.storage.db import Base
|
|
54
|
+
|
|
55
|
+
SCRIPT_LOCATION = Path(__file__).resolve().parent / "schema_migrations"
|
|
56
|
+
|
|
57
|
+
# Structural differences that make an unstamped database unsafe to stamp. Index, constraint, type and
|
|
58
|
+
# default differences between `create_all` and the migrations are deliberately NOT included: they do
|
|
59
|
+
# not change what data the tables can hold.
|
|
60
|
+
_STRUCTURAL_DIFFS = ("add_table", "remove_table", "add_column", "remove_column")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class MigrationRefused(Exception):
|
|
64
|
+
"""`migrate` will not change this database (diverged or from a newer CodePulse); nothing was touched."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_alembic_config(db_url: str | None = None) -> Config:
|
|
68
|
+
"""
|
|
69
|
+
An Alembic `Config` built in code: the scripts are the packaged ones and no `alembic.ini` is needed.
|
|
70
|
+
"""
|
|
71
|
+
cfg = Config()
|
|
72
|
+
cfg.set_main_option("script_location", str(SCRIPT_LOCATION))
|
|
73
|
+
# configparser treats % as interpolation; a percent-encoded URL would otherwise break.
|
|
74
|
+
cfg.set_main_option("sqlalchemy.url", (db_url or get_settings().db_url).replace("%", "%%"))
|
|
75
|
+
return cfg
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@lru_cache(maxsize=1)
|
|
79
|
+
def head_revision() -> str:
|
|
80
|
+
"""The newest revision this CodePulse knows (computed once per process)."""
|
|
81
|
+
head = ScriptDirectory.from_config(get_alembic_config("sqlite://")).get_current_head()
|
|
82
|
+
assert head, "no migration revisions found"
|
|
83
|
+
return head
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_migrations(db_url: str | None = None, revision: str = "head") -> None:
|
|
87
|
+
"""Applies Alembic migrations up to the specified revision (default: head)."""
|
|
88
|
+
command.upgrade(get_alembic_config(db_url=db_url), revision)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def stamp_database(db_url: str | None = None, revision: str = "head") -> None:
|
|
92
|
+
"""
|
|
93
|
+
Stamps the database at the specified revision without running migration scripts.
|
|
94
|
+
Only correct for a database whose schema already matches that revision;
|
|
95
|
+
`migrate_database` checks that before it calls this.
|
|
96
|
+
"""
|
|
97
|
+
command.stamp(get_alembic_config(db_url=db_url), revision)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def downgrade_migrations(db_url: str | None = None, revision: str = "base") -> None:
|
|
101
|
+
"""Downgrades Alembic migrations to the specified revision (default: base)."""
|
|
102
|
+
command.downgrade(get_alembic_config(db_url=db_url), revision)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_current_revision(db_url: str | None = None) -> Optional[str]:
|
|
106
|
+
"""The database's alembic_version revision, or None if it is not tracked (or does not exist)."""
|
|
107
|
+
target_url = db_url or get_settings().db_url
|
|
108
|
+
path = _sqlite_file(target_url)
|
|
109
|
+
if path is not None and not path.exists():
|
|
110
|
+
return None
|
|
111
|
+
engine = create_engine(target_url)
|
|
112
|
+
try:
|
|
113
|
+
if "alembic_version" not in inspect(engine).get_table_names():
|
|
114
|
+
return None
|
|
115
|
+
with engine.connect() as conn:
|
|
116
|
+
return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
|
117
|
+
finally:
|
|
118
|
+
engine.dispose()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def stamp_new_database(engine: Engine) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Records head as the revision of a database that `StorageManager` has JUST created from scratch with
|
|
124
|
+
`create_all` (so its schema IS head). Before this, such a database was never tracked by Alembic and
|
|
125
|
+
could not be upgraded later. Safe if two processes create the same new database at once.
|
|
126
|
+
"""
|
|
127
|
+
head = head_revision()
|
|
128
|
+
try:
|
|
129
|
+
with engine.begin() as conn:
|
|
130
|
+
conn.execute(text(
|
|
131
|
+
"CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL, "
|
|
132
|
+
"CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))"
|
|
133
|
+
))
|
|
134
|
+
if conn.execute(text("SELECT count(*) FROM alembic_version")).scalar() == 0:
|
|
135
|
+
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:v)"), {"v": head})
|
|
136
|
+
except IntegrityError:
|
|
137
|
+
pass # the other process stamped it first
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- status ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class SchemaStatus:
|
|
144
|
+
state: str # fresh | current | behind | unstamped | diverged | unknown
|
|
145
|
+
current: Optional[str]
|
|
146
|
+
head: str
|
|
147
|
+
pending: list[str] = field(default_factory=list) # revisions `migrate` would apply, oldest first
|
|
148
|
+
differences: list[str] = field(default_factory=list) # for diverged: what differs from the models
|
|
149
|
+
missing_tables: list[str] = field(default_factory=list) # for unstamped: tables `migrate` will create
|
|
150
|
+
detail: str = ""
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def needs_action(self) -> bool:
|
|
154
|
+
return self.state in ("behind", "unstamped", "diverged", "unknown")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _sqlite_file(db_url: str) -> Optional[Path]:
|
|
158
|
+
url = make_url(db_url)
|
|
159
|
+
if url.get_backend_name() == "sqlite" and url.database and url.database != ":memory:":
|
|
160
|
+
return Path(url.database)
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _pending(current: str) -> list[str]:
|
|
165
|
+
script = ScriptDirectory.from_config(get_alembic_config("sqlite://"))
|
|
166
|
+
revisions = [rev.revision for rev in script.iterate_revisions(head_revision(), current)]
|
|
167
|
+
return [rev for rev in reversed(revisions) if rev != current]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _structural_differences(engine: Engine) -> tuple[list[str], list[str]]:
|
|
171
|
+
"""
|
|
172
|
+
`(differences, missing_tables)`: what in the database does NOT match the models, split into things that
|
|
173
|
+
make it unsafe to stamp (`differences`) and tables that are merely absent (`missing_tables`). Creating an
|
|
174
|
+
absent empty table cannot conflict with existing data, so those alone do not block.
|
|
175
|
+
"""
|
|
176
|
+
with engine.connect() as conn:
|
|
177
|
+
context = MigrationContext.configure(conn, opts={"compare_type": False, "compare_server_default": False})
|
|
178
|
+
diffs = compare_metadata(context, Base.metadata)
|
|
179
|
+
out: list[str] = []
|
|
180
|
+
missing: list[str] = []
|
|
181
|
+
for diff in diffs:
|
|
182
|
+
kind = diff[0]
|
|
183
|
+
if kind == "add_table":
|
|
184
|
+
missing.append(diff[1].name)
|
|
185
|
+
elif kind == "remove_table":
|
|
186
|
+
if diff[1].name != "alembic_version":
|
|
187
|
+
out.append(f"table {diff[1].name} is not in the models")
|
|
188
|
+
elif kind == "add_column":
|
|
189
|
+
out.append(f"column {diff[2]}.{diff[3].name} is missing")
|
|
190
|
+
elif kind == "remove_column":
|
|
191
|
+
out.append(f"column {diff[2]}.{diff[3].name} is not in the models")
|
|
192
|
+
return sorted(out), sorted(missing)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_schema_status(db_url: str | None = None) -> SchemaStatus:
|
|
196
|
+
"""Classifies the database (see the module docstring). Never creates or changes it."""
|
|
197
|
+
target_url = db_url or get_settings().db_url
|
|
198
|
+
head = head_revision()
|
|
199
|
+
path = _sqlite_file(target_url)
|
|
200
|
+
if path is not None and (not path.exists() or path.stat().st_size == 0):
|
|
201
|
+
return SchemaStatus("fresh", None, head, detail="no database yet")
|
|
202
|
+
|
|
203
|
+
engine = create_engine(target_url)
|
|
204
|
+
try:
|
|
205
|
+
tables = set(inspect(engine).get_table_names())
|
|
206
|
+
user_tables = tables - {"alembic_version"}
|
|
207
|
+
current = None
|
|
208
|
+
if "alembic_version" in tables:
|
|
209
|
+
with engine.connect() as conn:
|
|
210
|
+
current = conn.execute(text("SELECT version_num FROM alembic_version")).scalar() or None
|
|
211
|
+
if not user_tables:
|
|
212
|
+
return SchemaStatus("fresh", current, head, detail="no tables yet")
|
|
213
|
+
if current is not None:
|
|
214
|
+
if current == head:
|
|
215
|
+
return SchemaStatus("current", current, head, detail=f"at {head}")
|
|
216
|
+
known = {rev.revision for rev in ScriptDirectory.from_config(get_alembic_config("sqlite://")).walk_revisions()}
|
|
217
|
+
if current not in known:
|
|
218
|
+
return SchemaStatus(
|
|
219
|
+
"unknown", current, head,
|
|
220
|
+
detail=f"revision {current} is not known to this CodePulse (a database from a newer version?)",
|
|
221
|
+
)
|
|
222
|
+
pending = _pending(current)
|
|
223
|
+
return SchemaStatus("behind", current, head, pending, detail=f"at {current}, {len(pending)} revision(s) behind {head}")
|
|
224
|
+
differences, missing_tables = _structural_differences(engine)
|
|
225
|
+
if differences:
|
|
226
|
+
return SchemaStatus(
|
|
227
|
+
"diverged", None, head, differences=differences,
|
|
228
|
+
missing_tables=missing_tables,
|
|
229
|
+
detail="not tracked by Alembic and its schema differs from the models: "
|
|
230
|
+
+ "; ".join(differences + [f"table {t} is missing" for t in missing_tables]),
|
|
231
|
+
)
|
|
232
|
+
if missing_tables:
|
|
233
|
+
return SchemaStatus(
|
|
234
|
+
"unstamped", None, head, missing_tables=missing_tables,
|
|
235
|
+
detail=f"not tracked by Alembic; its schema matches the models except for missing table(s) {', '.join(missing_tables)}, which migrate will create",
|
|
236
|
+
)
|
|
237
|
+
return SchemaStatus("unstamped", None, head, detail="not tracked by Alembic, but its schema matches the models")
|
|
238
|
+
finally:
|
|
239
|
+
engine.dispose()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --- migrate -----------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class MigrationResult:
|
|
246
|
+
action: str # created | up-to-date | upgraded | stamped | needs-upgrade | needs-stamp | blocked
|
|
247
|
+
state: str
|
|
248
|
+
from_revision: Optional[str]
|
|
249
|
+
to_revision: Optional[str]
|
|
250
|
+
backup_path: Optional[str] = None
|
|
251
|
+
applied: list[str] = field(default_factory=list)
|
|
252
|
+
detail: str = ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _backup_sqlite(path: Path, label: str) -> str:
|
|
256
|
+
"""A consistent copy of a live SQLite database (the sqlite3 backup API, safe with a writer present)."""
|
|
257
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
258
|
+
dest = path.with_name(f"{path.name}.pre-{label}-{stamp}.bak")
|
|
259
|
+
counter = 1
|
|
260
|
+
while dest.exists():
|
|
261
|
+
counter += 1
|
|
262
|
+
dest = path.with_name(f"{path.name}.pre-{label}-{stamp}-{counter}.bak")
|
|
263
|
+
source = sqlite3.connect(str(path))
|
|
264
|
+
target = sqlite3.connect(str(dest))
|
|
265
|
+
try:
|
|
266
|
+
source.backup(target)
|
|
267
|
+
finally:
|
|
268
|
+
target.close()
|
|
269
|
+
source.close()
|
|
270
|
+
return str(dest)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _verify(db_url: str, backup_path: Optional[str]) -> None:
|
|
274
|
+
if get_current_revision(db_url) != head_revision():
|
|
275
|
+
raise RuntimeError(f"migration finished but the database is not at {head_revision()}" + _restore_hint(backup_path))
|
|
276
|
+
path = _sqlite_file(db_url)
|
|
277
|
+
if path is not None:
|
|
278
|
+
conn = sqlite3.connect(str(path))
|
|
279
|
+
try:
|
|
280
|
+
result = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
|
281
|
+
finally:
|
|
282
|
+
conn.close()
|
|
283
|
+
if result != "ok":
|
|
284
|
+
raise RuntimeError(f"integrity check failed after migrating: {result}" + _restore_hint(backup_path))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _restore_hint(backup_path: Optional[str]) -> str:
|
|
288
|
+
return f" (a backup of the previous state is at {backup_path})" if backup_path else ""
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def migrate_database(db_url: str | None = None, *, backup: bool = True, check: bool = False) -> MigrationResult:
|
|
292
|
+
"""
|
|
293
|
+
Brings the database to head, safely (see the module docstring for what each state does).
|
|
294
|
+
|
|
295
|
+
`check=True` only reports what would happen and never changes anything. Otherwise a `diverged` or
|
|
296
|
+
`unknown` database raises `MigrationRefused` and is left exactly as it was. A SQLite file is backed up
|
|
297
|
+
(unless `backup=False`) before it is upgraded or stamped; a database that did not exist has nothing to
|
|
298
|
+
back up.
|
|
299
|
+
"""
|
|
300
|
+
target_url = db_url or get_settings().db_url
|
|
301
|
+
status = get_schema_status(target_url)
|
|
302
|
+
head = status.head
|
|
303
|
+
path = _sqlite_file(target_url)
|
|
304
|
+
|
|
305
|
+
if check:
|
|
306
|
+
action = {
|
|
307
|
+
"fresh": "up-to-date", "current": "up-to-date", "behind": "needs-upgrade",
|
|
308
|
+
"unstamped": "needs-stamp", "diverged": "blocked", "unknown": "blocked",
|
|
309
|
+
}[status.state]
|
|
310
|
+
return MigrationResult(action, status.state, status.current, head, applied=list(status.pending), detail=status.detail)
|
|
311
|
+
|
|
312
|
+
if status.state == "current":
|
|
313
|
+
return MigrationResult("up-to-date", "current", status.current, head, detail=status.detail)
|
|
314
|
+
if status.state in ("diverged", "unknown"):
|
|
315
|
+
raise MigrationRefused(
|
|
316
|
+
status.detail + ". Nothing was changed. "
|
|
317
|
+
+ ("Back the file up and rebuild it by re-indexing, or migrate it by hand." if status.state == "diverged"
|
|
318
|
+
else "Upgrade CodePulse instead of migrating this database.")
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
backup_path: Optional[str] = None
|
|
322
|
+
if status.state == "fresh":
|
|
323
|
+
run_migrations(target_url)
|
|
324
|
+
_verify(target_url, None)
|
|
325
|
+
return MigrationResult("created", "fresh", None, head, applied=[head], detail="created the schema")
|
|
326
|
+
|
|
327
|
+
if backup and path is not None:
|
|
328
|
+
backup_path = _backup_sqlite(path, status.current or "unstamped")
|
|
329
|
+
if status.state == "behind":
|
|
330
|
+
run_migrations(target_url)
|
|
331
|
+
_verify(target_url, backup_path)
|
|
332
|
+
return MigrationResult("upgraded", "behind", status.current, head, backup_path, list(status.pending), f"upgraded {status.current} -> {head}")
|
|
333
|
+
# unstamped and matching the models, apart from tables it does not have yet
|
|
334
|
+
created: list[str] = []
|
|
335
|
+
if status.missing_tables:
|
|
336
|
+
engine = create_engine(target_url)
|
|
337
|
+
try:
|
|
338
|
+
Base.metadata.create_all(engine, tables=[Base.metadata.tables[name] for name in status.missing_tables])
|
|
339
|
+
finally:
|
|
340
|
+
engine.dispose()
|
|
341
|
+
created = list(status.missing_tables)
|
|
342
|
+
stamp_database(target_url)
|
|
343
|
+
_verify(target_url, backup_path)
|
|
344
|
+
detail = f"stamped at {head} (its schema matches the models)"
|
|
345
|
+
if created:
|
|
346
|
+
detail = f"created missing table(s) {', '.join(created)} and stamped at {head}"
|
|
347
|
+
return MigrationResult("stamped", "unstamped", None, head, backup_path, [], detail)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Generic single-database configuration.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from logging.config import fileConfig
|
|
4
|
+
from sqlalchemy import engine_from_config, pool
|
|
5
|
+
from alembic import context
|
|
6
|
+
|
|
7
|
+
from codepulse.config import get_settings
|
|
8
|
+
from codepulse.storage.db import Base
|
|
9
|
+
|
|
10
|
+
# this is the Alembic Config object, which provides
|
|
11
|
+
# access to the values within the .ini file in use.
|
|
12
|
+
config = context.config
|
|
13
|
+
|
|
14
|
+
# Interpret the config file for Python logging.
|
|
15
|
+
if config.config_file_name is not None:
|
|
16
|
+
fileConfig(config.config_file_name)
|
|
17
|
+
|
|
18
|
+
# Model metadata for autogenerate support
|
|
19
|
+
target_metadata = Base.metadata
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_db_url() -> str:
|
|
23
|
+
"""Returns the database URL from config file option, or defaults to CodePulseConfig."""
|
|
24
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
25
|
+
if not url or url.strip() == "" or url == "driver://user:pass@localhost/dbname":
|
|
26
|
+
url = get_settings().db_url
|
|
27
|
+
return url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_migrations_offline() -> None:
|
|
31
|
+
"""Run migrations in 'offline' mode."""
|
|
32
|
+
url = get_db_url()
|
|
33
|
+
context.configure(
|
|
34
|
+
url=url,
|
|
35
|
+
target_metadata=target_metadata,
|
|
36
|
+
literal_binds=True,
|
|
37
|
+
dialect_opts={"paramstyle": "named"},
|
|
38
|
+
render_as_batch=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
with context.begin_transaction():
|
|
42
|
+
context.run_migrations()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_migrations_online() -> None:
|
|
46
|
+
"""Run migrations in 'online' mode."""
|
|
47
|
+
configuration = config.get_section(config.config_ini_section, {})
|
|
48
|
+
configuration["sqlalchemy.url"] = get_db_url()
|
|
49
|
+
|
|
50
|
+
connectable = engine_from_config(
|
|
51
|
+
configuration,
|
|
52
|
+
prefix="sqlalchemy.",
|
|
53
|
+
poolclass=pool.NullPool,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
with connectable.connect() as connection:
|
|
57
|
+
context.configure(
|
|
58
|
+
connection=connection,
|
|
59
|
+
target_metadata=target_metadata,
|
|
60
|
+
render_as_batch=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with context.begin_transaction():
|
|
64
|
+
context.run_migrations()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if context.is_offline_mode():
|
|
68
|
+
run_migrations_offline()
|
|
69
|
+
else:
|
|
70
|
+
run_migrations_online()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
"""Upgrade schema."""
|
|
23
|
+
${upgrades if upgrades else "pass"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
"""Downgrade schema."""
|
|
28
|
+
${downgrades if downgrades else "pass"}
|
codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""add_base_classes_and_relationship_type
|
|
2
|
+
|
|
3
|
+
Revision ID: 135a7b064ea7
|
|
4
|
+
Revises: 2cd38eb12a7e
|
|
5
|
+
Create Date: 2026-09-19 00:00:00.000000
|
|
6
|
+
|
|
7
|
+
Phase 4.1 (roadmap §37 item 4.1: "Base-class/inheritance extraction
|
|
8
|
+
(Python + JS/TS)"; §29 Data Model: "Symbol | Yes (exists, needs upsert
|
|
9
|
+
key + base-class field) | Core"). Architecture approved explicitly by
|
|
10
|
+
the user before implementation (see CODEPULSE_PROGRESS.md): raw
|
|
11
|
+
base-class text lives on `Symbol`/`symbols`; resolved inheritance
|
|
12
|
+
relationships are persisted as `CallEdge`/`call_edges` rows,
|
|
13
|
+
distinguished from ordinary call edges by a new `relationship_type`
|
|
14
|
+
column, reusing that table's existing `repo_id` scoping, `confidence`
|
|
15
|
+
vocabulary, and Phase 3.5 incremental-update machinery rather than
|
|
16
|
+
introducing a second persistence mechanism.
|
|
17
|
+
|
|
18
|
+
This migration is purely additive and non-destructive:
|
|
19
|
+
|
|
20
|
+
1. Adds `symbols.base_classes` (nullable `TEXT`, JSON-serialized
|
|
21
|
+
`list[str]`, the same "Text column holding a JSON-serialized list,
|
|
22
|
+
parsed in application code" convention `EmbeddingModel.
|
|
23
|
+
embedding_json` already uses -- not a new serialization approach).
|
|
24
|
+
`NULL` for every existing row (none of them were ever asked for
|
|
25
|
+
this data before this migration existed -- not a guess, an honest
|
|
26
|
+
absence, exactly like `confidence`'s own precedent in
|
|
27
|
+
`f6e481353553`) and for every non-CLASS symbol going forward.
|
|
28
|
+
Every existing row's `id`/`name`/`start_line`/`body_hash`/etc. is
|
|
29
|
+
untouched.
|
|
30
|
+
|
|
31
|
+
2. Adds `call_edges.relationship_type` (`VARCHAR(20)`, `NOT NULL`,
|
|
32
|
+
`DEFAULT 'calls'`). Every one of this table's existing rows is
|
|
33
|
+
UNAMBIGUOUSLY a call -- nothing else has ever been written to it
|
|
34
|
+
(confirmed: no code path existed to write anything else before
|
|
35
|
+
this migration) -- so `'calls'` is the correct backfill for every
|
|
36
|
+
existing row, not a guess in the way `confidence`'s or `repo_id`'s
|
|
37
|
+
own backfills had to be conservative about ambiguity. SQLite
|
|
38
|
+
applies the column default to every existing row automatically
|
|
39
|
+
when a `NOT NULL DEFAULT` column is added.
|
|
40
|
+
|
|
41
|
+
Both additions use Alembic "batch" mode (SQLite table-rebuild-under-
|
|
42
|
+
the-hood), matching every prior column-addition migration in this
|
|
43
|
+
project's history, even though a plain `ALTER TABLE ADD COLUMN` with a
|
|
44
|
+
constant default would likely work directly here -- consistency with
|
|
45
|
+
established migration style, not a functional requirement.
|
|
46
|
+
"""
|
|
47
|
+
from typing import Sequence, Union
|
|
48
|
+
|
|
49
|
+
from alembic import op
|
|
50
|
+
import sqlalchemy as sa
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# revision identifiers, used by Alembic.
|
|
54
|
+
revision: str = '135a7b064ea7'
|
|
55
|
+
down_revision: Union[str, Sequence[str], None] = '2cd38eb12a7e'
|
|
56
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
57
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def upgrade() -> None:
|
|
61
|
+
"""Upgrade schema."""
|
|
62
|
+
with op.batch_alter_table('symbols', schema=None) as batch_op:
|
|
63
|
+
batch_op.add_column(sa.Column('base_classes', sa.Text(), nullable=True))
|
|
64
|
+
|
|
65
|
+
with op.batch_alter_table('call_edges', schema=None) as batch_op:
|
|
66
|
+
batch_op.add_column(
|
|
67
|
+
sa.Column(
|
|
68
|
+
'relationship_type',
|
|
69
|
+
sa.String(length=20),
|
|
70
|
+
nullable=False,
|
|
71
|
+
server_default='calls',
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def downgrade() -> None:
|
|
77
|
+
"""Downgrade schema."""
|
|
78
|
+
with op.batch_alter_table('call_edges', schema=None) as batch_op:
|
|
79
|
+
batch_op.drop_column('relationship_type')
|
|
80
|
+
|
|
81
|
+
with op.batch_alter_table('symbols', schema=None) as batch_op:
|
|
82
|
+
batch_op.drop_column('base_classes')
|
codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""add_file_content_hash_and_natural_key_uniqueness
|
|
2
|
+
|
|
3
|
+
Revision ID: 20fc2d0607e2
|
|
4
|
+
Revises: 21a7cb7f1a85
|
|
5
|
+
Create Date: 2026-09-05 12:55:39.151635
|
|
6
|
+
|
|
7
|
+
Phase 0.3 (Repository/File upsert with content_hash).
|
|
8
|
+
|
|
9
|
+
This migration is additive and non-destructive:
|
|
10
|
+
|
|
11
|
+
- Adds `files.content_hash` (nullable). Existing File rows predate content
|
|
12
|
+
hashing and only ever stored path/language/size/line-count metadata --
|
|
13
|
+
the actual source bytes that were indexed were never persisted anywhere
|
|
14
|
+
in the database (Symbol.body only stores per-symbol snippets, not whole
|
|
15
|
+
files), so there is no reliable, non-fabricated way to compute a
|
|
16
|
+
historically-accurate hash for pre-existing rows during this migration.
|
|
17
|
+
Per the Phase 0.3 spec, we do NOT invent/backfill a value for them --
|
|
18
|
+
`content_hash` is left NULL for rows that predate this migration. Each
|
|
19
|
+
such row gets a real, accurate content_hash automatically the next time
|
|
20
|
+
its file is re-indexed by the application (see
|
|
21
|
+
IndexingEngine._persist_to_db), at which point the current on-disk
|
|
22
|
+
content is genuinely available to hash.
|
|
23
|
+
|
|
24
|
+
- Adds a UNIQUE constraint on `files(repo_id, path)` and on
|
|
25
|
+
`repositories(url)`, enforcing the natural-key invariants the Phase 0.3
|
|
26
|
+
upsert logic depends on. A pre-flight check aborts the migration (rather
|
|
27
|
+
than failing partway through a batch table-recreate, or silently
|
|
28
|
+
accepting an inconsistent state) if any pre-existing duplicate rows
|
|
29
|
+
would violate these constraints.
|
|
30
|
+
|
|
31
|
+
No table is dropped or recreated destructively and no existing row is
|
|
32
|
+
deleted or overwritten. SQLAlchemy/Alembic "batch" mode is used because
|
|
33
|
+
SQLite requires a copy-and-swap under the hood to add a constraint to an
|
|
34
|
+
existing table -- all existing rows and columns are carried over unchanged
|
|
35
|
+
by that process.
|
|
36
|
+
"""
|
|
37
|
+
from typing import Sequence, Union
|
|
38
|
+
|
|
39
|
+
from alembic import op
|
|
40
|
+
import sqlalchemy as sa
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# revision identifiers, used by Alembic.
|
|
44
|
+
revision: str = '20fc2d0607e2'
|
|
45
|
+
down_revision: Union[str, Sequence[str], None] = '21a7cb7f1a85'
|
|
46
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
47
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _assert_no_duplicates() -> None:
|
|
51
|
+
"""
|
|
52
|
+
Pre-flight safety check: refuse to add the UNIQUE constraints if any
|
|
53
|
+
pre-existing data would violate them, rather than letting the batch
|
|
54
|
+
table-recreate fail halfway (or, on backends that don't error the same
|
|
55
|
+
way, silently accepting an inconsistent state).
|
|
56
|
+
"""
|
|
57
|
+
bind = op.get_bind()
|
|
58
|
+
|
|
59
|
+
dup_repo_urls = bind.execute(
|
|
60
|
+
sa.text(
|
|
61
|
+
"SELECT url, COUNT(*) AS c FROM repositories "
|
|
62
|
+
"GROUP BY url HAVING COUNT(*) > 1"
|
|
63
|
+
)
|
|
64
|
+
).fetchall()
|
|
65
|
+
if dup_repo_urls:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"Cannot add UNIQUE constraint on repositories.url: duplicate "
|
|
68
|
+
f"url values already exist: {[row[0] for row in dup_repo_urls]}. "
|
|
69
|
+
"Resolve duplicate Repository rows before applying this migration."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
dup_files = bind.execute(
|
|
73
|
+
sa.text(
|
|
74
|
+
"SELECT repo_id, path, COUNT(*) AS c FROM files "
|
|
75
|
+
"GROUP BY repo_id, path HAVING COUNT(*) > 1"
|
|
76
|
+
)
|
|
77
|
+
).fetchall()
|
|
78
|
+
if dup_files:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"Cannot add UNIQUE constraint on files(repo_id, path): duplicate "
|
|
81
|
+
f"(repo_id, path) pairs already exist: "
|
|
82
|
+
f"{[(row[0], row[1]) for row in dup_files]}. Resolve duplicate "
|
|
83
|
+
"File rows before applying this migration."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def upgrade() -> None:
|
|
88
|
+
"""Upgrade schema."""
|
|
89
|
+
_assert_no_duplicates()
|
|
90
|
+
|
|
91
|
+
with op.batch_alter_table("files", schema=None) as batch_op:
|
|
92
|
+
batch_op.add_column(sa.Column("content_hash", sa.String(length=64), nullable=True))
|
|
93
|
+
batch_op.create_unique_constraint("uq_files_repo_id_path", ["repo_id", "path"])
|
|
94
|
+
|
|
95
|
+
with op.batch_alter_table("repositories", schema=None) as batch_op:
|
|
96
|
+
batch_op.create_unique_constraint("uq_repositories_url", ["url"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def downgrade() -> None:
|
|
100
|
+
"""Downgrade schema."""
|
|
101
|
+
with op.batch_alter_table("repositories", schema=None) as batch_op:
|
|
102
|
+
batch_op.drop_constraint("uq_repositories_url", type_="unique")
|
|
103
|
+
|
|
104
|
+
with op.batch_alter_table("files", schema=None) as batch_op:
|
|
105
|
+
batch_op.drop_constraint("uq_files_repo_id_path", type_="unique")
|
|
106
|
+
batch_op.drop_column("content_hash")
|