popeye-cli 1.7.0 → 1.9.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.
- package/README.md +148 -7
- package/cheatsheet.md +440 -0
- package/dist/cli/commands/db.d.ts +10 -0
- package/dist/cli/commands/db.d.ts.map +1 -0
- package/dist/cli/commands/db.js +240 -0
- package/dist/cli/commands/db.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +18 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +255 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/index.d.ts +3 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +3 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/review.d.ts +31 -0
- package/dist/cli/commands/review.d.ts.map +1 -0
- package/dist/cli/commands/review.js +156 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +218 -61
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/admin-wizard.d.ts +25 -0
- package/dist/generators/admin-wizard.d.ts.map +1 -0
- package/dist/generators/admin-wizard.js +123 -0
- package/dist/generators/admin-wizard.js.map +1 -0
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +10 -3
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/database.d.ts +58 -0
- package/dist/generators/database.d.ts.map +1 -0
- package/dist/generators/database.js +229 -0
- package/dist/generators/database.js.map +1 -0
- package/dist/generators/fullstack.d.ts.map +1 -1
- package/dist/generators/fullstack.js +23 -7
- package/dist/generators/fullstack.js.map +1 -1
- package/dist/generators/index.d.ts +2 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -0
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/templates/admin-wizard-python.d.ts +32 -0
- package/dist/generators/templates/admin-wizard-python.d.ts.map +1 -0
- package/dist/generators/templates/admin-wizard-python.js +425 -0
- package/dist/generators/templates/admin-wizard-python.js.map +1 -0
- package/dist/generators/templates/admin-wizard-react.d.ts +48 -0
- package/dist/generators/templates/admin-wizard-react.d.ts.map +1 -0
- package/dist/generators/templates/admin-wizard-react.js +554 -0
- package/dist/generators/templates/admin-wizard-react.js.map +1 -0
- package/dist/generators/templates/database-docker.d.ts +23 -0
- package/dist/generators/templates/database-docker.d.ts.map +1 -0
- package/dist/generators/templates/database-docker.js +221 -0
- package/dist/generators/templates/database-docker.js.map +1 -0
- package/dist/generators/templates/database-python.d.ts +54 -0
- package/dist/generators/templates/database-python.d.ts.map +1 -0
- package/dist/generators/templates/database-python.js +723 -0
- package/dist/generators/templates/database-python.js.map +1 -0
- package/dist/generators/templates/database-typescript.d.ts +34 -0
- package/dist/generators/templates/database-typescript.d.ts.map +1 -0
- package/dist/generators/templates/database-typescript.js +232 -0
- package/dist/generators/templates/database-typescript.js.map +1 -0
- package/dist/generators/templates/fullstack.d.ts.map +1 -1
- package/dist/generators/templates/fullstack.js +29 -0
- package/dist/generators/templates/fullstack.js.map +1 -1
- package/dist/generators/templates/index.d.ts +5 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +5 -0
- package/dist/generators/templates/index.js.map +1 -1
- package/dist/state/index.d.ts +10 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +21 -0
- package/dist/state/index.js.map +1 -1
- package/dist/types/audit.d.ts +623 -0
- package/dist/types/audit.d.ts.map +1 -0
- package/dist/types/audit.js +240 -0
- package/dist/types/audit.js.map +1 -0
- package/dist/types/database-runtime.d.ts +86 -0
- package/dist/types/database-runtime.d.ts.map +1 -0
- package/dist/types/database-runtime.js +61 -0
- package/dist/types/database-runtime.js.map +1 -0
- package/dist/types/database.d.ts +85 -0
- package/dist/types/database.d.ts.map +1 -0
- package/dist/types/database.js +71 -0
- package/dist/types/database.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +4 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/workflow.d.ts +36 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +7 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/workflow/audit-analyzer.d.ts +58 -0
- package/dist/workflow/audit-analyzer.d.ts.map +1 -0
- package/dist/workflow/audit-analyzer.js +420 -0
- package/dist/workflow/audit-analyzer.js.map +1 -0
- package/dist/workflow/audit-mode.d.ts +28 -0
- package/dist/workflow/audit-mode.d.ts.map +1 -0
- package/dist/workflow/audit-mode.js +169 -0
- package/dist/workflow/audit-mode.js.map +1 -0
- package/dist/workflow/audit-recovery.d.ts +61 -0
- package/dist/workflow/audit-recovery.d.ts.map +1 -0
- package/dist/workflow/audit-recovery.js +242 -0
- package/dist/workflow/audit-recovery.js.map +1 -0
- package/dist/workflow/audit-reporter.d.ts +65 -0
- package/dist/workflow/audit-reporter.d.ts.map +1 -0
- package/dist/workflow/audit-reporter.js +301 -0
- package/dist/workflow/audit-reporter.js.map +1 -0
- package/dist/workflow/audit-scanner.d.ts +87 -0
- package/dist/workflow/audit-scanner.d.ts.map +1 -0
- package/dist/workflow/audit-scanner.js +768 -0
- package/dist/workflow/audit-scanner.js.map +1 -0
- package/dist/workflow/db-setup-runner.d.ts +63 -0
- package/dist/workflow/db-setup-runner.d.ts.map +1 -0
- package/dist/workflow/db-setup-runner.js +336 -0
- package/dist/workflow/db-setup-runner.js.map +1 -0
- package/dist/workflow/db-state-machine.d.ts +30 -0
- package/dist/workflow/db-state-machine.d.ts.map +1 -0
- package/dist/workflow/db-state-machine.js +51 -0
- package/dist/workflow/db-state-machine.js.map +1 -0
- package/dist/workflow/index.d.ts +7 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +7 -0
- package/dist/workflow/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/db.ts +281 -0
- package/src/cli/commands/doctor.ts +273 -0
- package/src/cli/commands/index.ts +3 -0
- package/src/cli/commands/review.ts +187 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/interactive.ts +174 -4
- package/src/generators/admin-wizard.ts +146 -0
- package/src/generators/all.ts +10 -3
- package/src/generators/database.ts +286 -0
- package/src/generators/fullstack.ts +26 -9
- package/src/generators/index.ts +12 -0
- package/src/generators/templates/admin-wizard-python.ts +431 -0
- package/src/generators/templates/admin-wizard-react.ts +560 -0
- package/src/generators/templates/database-docker.ts +227 -0
- package/src/generators/templates/database-python.ts +734 -0
- package/src/generators/templates/database-typescript.ts +238 -0
- package/src/generators/templates/fullstack.ts +29 -0
- package/src/generators/templates/index.ts +5 -0
- package/src/state/index.ts +28 -0
- package/src/types/audit.ts +294 -0
- package/src/types/database-runtime.ts +69 -0
- package/src/types/database.ts +84 -0
- package/src/types/index.ts +29 -0
- package/src/types/workflow.ts +20 -0
- package/src/workflow/audit-analyzer.ts +491 -0
- package/src/workflow/audit-mode.ts +240 -0
- package/src/workflow/audit-recovery.ts +284 -0
- package/src/workflow/audit-reporter.ts +370 -0
- package/src/workflow/audit-scanner.ts +873 -0
- package/src/workflow/db-setup-runner.ts +391 -0
- package/src/workflow/db-state-machine.ts +58 -0
- package/src/workflow/index.ts +7 -0
- package/tests/cli/commands/review.test.ts +52 -0
- package/tests/generators/admin-wizard-orchestrator.test.ts +64 -0
- package/tests/generators/admin-wizard-templates.test.ts +366 -0
- package/tests/generators/cross-phase-integration.test.ts +383 -0
- package/tests/generators/database.test.ts +456 -0
- package/tests/generators/fe-be-db-integration.test.ts +613 -0
- package/tests/types/audit.test.ts +250 -0
- package/tests/types/database-runtime.test.ts +158 -0
- package/tests/types/database.test.ts +187 -0
- package/tests/workflow/audit-analyzer.test.ts +281 -0
- package/tests/workflow/audit-mode.test.ts +114 -0
- package/tests/workflow/audit-recovery.test.ts +237 -0
- package/tests/workflow/audit-reporter.test.ts +254 -0
- package/tests/workflow/audit-scanner.test.ts +270 -0
- package/tests/workflow/db-setup-runner.test.ts +211 -0
- package/tests/workflow/db-state-machine.test.ts +117 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python backend database template functions
|
|
3
|
+
* Generates SQLAlchemy + Alembic + asyncpg + pgvector files
|
|
4
|
+
* Each function returns a string of the generated file content
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Generate database connection module with AsyncEngine and session management
|
|
8
|
+
*/
|
|
9
|
+
export function generateDbConnection(_packageName) {
|
|
10
|
+
return `"""
|
|
11
|
+
Database connection management.
|
|
12
|
+
|
|
13
|
+
Provides async engine, session factory, and FastAPI dependency.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from typing import AsyncGenerator
|
|
20
|
+
|
|
21
|
+
from sqlalchemy.ext.asyncio import (
|
|
22
|
+
AsyncSession,
|
|
23
|
+
async_sessionmaker,
|
|
24
|
+
create_async_engine,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
|
30
|
+
|
|
31
|
+
# Create async engine (lazy - only connects when DATABASE_URL is set)
|
|
32
|
+
engine = (
|
|
33
|
+
create_async_engine(
|
|
34
|
+
DATABASE_URL,
|
|
35
|
+
echo=False,
|
|
36
|
+
pool_size=5,
|
|
37
|
+
max_overflow=10,
|
|
38
|
+
pool_pre_ping=True,
|
|
39
|
+
)
|
|
40
|
+
if DATABASE_URL
|
|
41
|
+
else None
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Session factory
|
|
45
|
+
async_session_factory = (
|
|
46
|
+
async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
47
|
+
if engine
|
|
48
|
+
else None
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|
53
|
+
"""
|
|
54
|
+
FastAPI dependency that provides an async database session.
|
|
55
|
+
|
|
56
|
+
Yields:
|
|
57
|
+
AsyncSession: Database session (auto-committed on success, rolled back on error).
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
RuntimeError: If DATABASE_URL is not configured.
|
|
61
|
+
"""
|
|
62
|
+
if async_session_factory is None:
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
"Database not configured. Set DATABASE_URL environment variable."
|
|
65
|
+
)
|
|
66
|
+
async with async_session_factory() as session:
|
|
67
|
+
try:
|
|
68
|
+
yield session
|
|
69
|
+
await session.commit()
|
|
70
|
+
except Exception:
|
|
71
|
+
await session.rollback()
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def check_db_connection() -> dict:
|
|
76
|
+
"""
|
|
77
|
+
Health check helper - tests database connectivity.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
dict: Connection status with details.
|
|
81
|
+
"""
|
|
82
|
+
if engine is None:
|
|
83
|
+
return {"connected": False, "error": "DATABASE_URL not configured"}
|
|
84
|
+
try:
|
|
85
|
+
async with engine.connect() as conn:
|
|
86
|
+
result = await conn.execute(
|
|
87
|
+
__import__("sqlalchemy").text("SELECT 1")
|
|
88
|
+
)
|
|
89
|
+
result.scalar()
|
|
90
|
+
return {"connected": True}
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(f"Database connection check failed: {e}")
|
|
93
|
+
return {"connected": False, "error": str(e)}
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Generate database models with Base, TimestampMixin, and AppSettings
|
|
98
|
+
*/
|
|
99
|
+
export function generateDbModels(_packageName) {
|
|
100
|
+
return `"""
|
|
101
|
+
Database models.
|
|
102
|
+
|
|
103
|
+
Provides SQLAlchemy declarative base, mixins, and core models.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
import datetime
|
|
107
|
+
from sqlalchemy import Column, DateTime, Integer, String, Text, func
|
|
108
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Base(DeclarativeBase):
|
|
112
|
+
"""Declarative base for all models."""
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TimestampMixin:
|
|
117
|
+
"""
|
|
118
|
+
Mixin that adds created_at and updated_at columns.
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
class MyModel(Base, TimestampMixin):
|
|
122
|
+
__tablename__ = "my_table"
|
|
123
|
+
...
|
|
124
|
+
"""
|
|
125
|
+
created_at: Mapped[datetime.datetime] = mapped_column(
|
|
126
|
+
DateTime(timezone=True),
|
|
127
|
+
server_default=func.now(),
|
|
128
|
+
nullable=False,
|
|
129
|
+
)
|
|
130
|
+
updated_at: Mapped[datetime.datetime] = mapped_column(
|
|
131
|
+
DateTime(timezone=True),
|
|
132
|
+
server_default=func.now(),
|
|
133
|
+
onupdate=func.now(),
|
|
134
|
+
nullable=False,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AppSettings(Base, TimestampMixin):
|
|
139
|
+
"""
|
|
140
|
+
Key-value configuration storage.
|
|
141
|
+
|
|
142
|
+
Used for runtime settings that persist across restarts.
|
|
143
|
+
"""
|
|
144
|
+
__tablename__ = "app_settings"
|
|
145
|
+
|
|
146
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
147
|
+
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
148
|
+
value: Mapped[str] = mapped_column(Text, nullable=False)
|
|
149
|
+
|
|
150
|
+
def __repr__(self) -> str:
|
|
151
|
+
return f"<AppSettings(key={self.key!r})>"
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Generate database package __init__.py with re-exports
|
|
156
|
+
*/
|
|
157
|
+
export function generateDbInit(_packageName) {
|
|
158
|
+
return `"""
|
|
159
|
+
Database package.
|
|
160
|
+
|
|
161
|
+
Re-exports core database components for convenient access.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
from .connection import engine, get_session, check_db_connection
|
|
165
|
+
from .models import Base, TimestampMixin, AppSettings
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"engine",
|
|
169
|
+
"get_session",
|
|
170
|
+
"check_db_connection",
|
|
171
|
+
"Base",
|
|
172
|
+
"TimestampMixin",
|
|
173
|
+
"AppSettings",
|
|
174
|
+
]
|
|
175
|
+
`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Generate database settings module using pydantic-settings
|
|
179
|
+
*/
|
|
180
|
+
export function generateDbSettings(_packageName) {
|
|
181
|
+
return `"""
|
|
182
|
+
Database settings.
|
|
183
|
+
|
|
184
|
+
Reads DATABASE_URL from environment with mode detection.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
import os
|
|
188
|
+
from pydantic_settings import BaseSettings
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DatabaseSettings(BaseSettings):
|
|
192
|
+
"""
|
|
193
|
+
Database configuration loaded from environment.
|
|
194
|
+
|
|
195
|
+
Attributes:
|
|
196
|
+
database_url: PostgreSQL connection string.
|
|
197
|
+
db_vector_required: Whether pgvector extension is needed.
|
|
198
|
+
"""
|
|
199
|
+
database_url: str = ""
|
|
200
|
+
db_vector_required: bool = True
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_configured(self) -> bool:
|
|
204
|
+
"""Check if a database URL has been provided."""
|
|
205
|
+
return bool(self.database_url)
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def is_local_docker(self) -> bool:
|
|
209
|
+
"""Detect if using local Docker PostgreSQL."""
|
|
210
|
+
return "localhost" in self.database_url or "postgres:" in self.database_url
|
|
211
|
+
|
|
212
|
+
class Config:
|
|
213
|
+
env_file = ".env"
|
|
214
|
+
extra = "ignore"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
db_settings = DatabaseSettings()
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Generate alembic.ini configuration
|
|
222
|
+
*/
|
|
223
|
+
export function generateAlembicIni(_packageName) {
|
|
224
|
+
return `[alembic]
|
|
225
|
+
script_location = migrations
|
|
226
|
+
prepend_sys_path = .
|
|
227
|
+
|
|
228
|
+
[loggers]
|
|
229
|
+
keys = root,sqlalchemy,alembic
|
|
230
|
+
|
|
231
|
+
[handlers]
|
|
232
|
+
keys = console
|
|
233
|
+
|
|
234
|
+
[formatters]
|
|
235
|
+
keys = generic
|
|
236
|
+
|
|
237
|
+
[logger_root]
|
|
238
|
+
level = WARN
|
|
239
|
+
handlers = console
|
|
240
|
+
qualname =
|
|
241
|
+
|
|
242
|
+
[logger_sqlalchemy]
|
|
243
|
+
level = WARN
|
|
244
|
+
handlers =
|
|
245
|
+
qualname = sqlalchemy.engine
|
|
246
|
+
|
|
247
|
+
[logger_alembic]
|
|
248
|
+
level = INFO
|
|
249
|
+
handlers =
|
|
250
|
+
qualname = alembic
|
|
251
|
+
|
|
252
|
+
[handler_console]
|
|
253
|
+
class = StreamHandler
|
|
254
|
+
args = (sys.stderr,)
|
|
255
|
+
level = NOTSET
|
|
256
|
+
formatter = generic
|
|
257
|
+
|
|
258
|
+
[formatter_generic]
|
|
259
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
260
|
+
datefmt = %H:%M:%S
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Generate Alembic async env.py
|
|
265
|
+
*/
|
|
266
|
+
export function generateAlembicEnvPy(packageName) {
|
|
267
|
+
return `"""
|
|
268
|
+
Alembic environment configuration.
|
|
269
|
+
|
|
270
|
+
Supports async migrations with SQLAlchemy.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
import asyncio
|
|
274
|
+
import os
|
|
275
|
+
from logging.config import fileConfig
|
|
276
|
+
|
|
277
|
+
from alembic import context
|
|
278
|
+
from sqlalchemy import pool
|
|
279
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
280
|
+
|
|
281
|
+
# Import models to register metadata
|
|
282
|
+
from src.${packageName}.database.models import Base
|
|
283
|
+
|
|
284
|
+
# Alembic Config object
|
|
285
|
+
config = context.config
|
|
286
|
+
|
|
287
|
+
# Set sqlalchemy.url from environment
|
|
288
|
+
database_url = os.getenv("DATABASE_URL", "")
|
|
289
|
+
if database_url:
|
|
290
|
+
config.set_main_option("sqlalchemy.url", database_url)
|
|
291
|
+
|
|
292
|
+
# Setup logging
|
|
293
|
+
if config.config_file_name is not None:
|
|
294
|
+
fileConfig(config.config_file_name)
|
|
295
|
+
|
|
296
|
+
# Target metadata for autogenerate
|
|
297
|
+
target_metadata = Base.metadata
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def run_migrations_offline() -> None:
|
|
301
|
+
"""
|
|
302
|
+
Run migrations in 'offline' mode.
|
|
303
|
+
|
|
304
|
+
Generates SQL script without connecting to the database.
|
|
305
|
+
"""
|
|
306
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
307
|
+
context.configure(
|
|
308
|
+
url=url,
|
|
309
|
+
target_metadata=target_metadata,
|
|
310
|
+
literal_binds=True,
|
|
311
|
+
dialect_opts={"paramstyle": "named"},
|
|
312
|
+
)
|
|
313
|
+
with context.begin_transaction():
|
|
314
|
+
context.run_migrations()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def do_run_migrations(connection):
|
|
318
|
+
"""Run migrations with the given connection."""
|
|
319
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
320
|
+
with context.begin_transaction():
|
|
321
|
+
context.run_migrations()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def run_migrations_online() -> None:
|
|
325
|
+
"""
|
|
326
|
+
Run migrations in 'online' mode.
|
|
327
|
+
|
|
328
|
+
Creates an async engine and runs migrations.
|
|
329
|
+
"""
|
|
330
|
+
connectable = async_engine_from_config(
|
|
331
|
+
config.get_section(config.config_ini_section, {}),
|
|
332
|
+
prefix="sqlalchemy.",
|
|
333
|
+
poolclass=pool.NullPool,
|
|
334
|
+
)
|
|
335
|
+
async with connectable.connect() as connection:
|
|
336
|
+
await connection.run_sync(do_run_migrations)
|
|
337
|
+
await connectable.dispose()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
if context.is_offline_mode():
|
|
341
|
+
run_migrations_offline()
|
|
342
|
+
else:
|
|
343
|
+
asyncio.run(run_migrations_online())
|
|
344
|
+
`;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Generate Alembic script.py.mako template
|
|
348
|
+
*/
|
|
349
|
+
export function generateAlembicScriptMako() {
|
|
350
|
+
return `"""$\{message}
|
|
351
|
+
|
|
352
|
+
Revision ID: $\{up_revision}
|
|
353
|
+
Revises: $\{down_revision | comma,n}
|
|
354
|
+
Create Date: $\{create_date}
|
|
355
|
+
"""
|
|
356
|
+
from typing import Sequence, Union
|
|
357
|
+
|
|
358
|
+
from alembic import op
|
|
359
|
+
import sqlalchemy as sa
|
|
360
|
+
$\{imports if imports else ""}
|
|
361
|
+
|
|
362
|
+
# revision identifiers
|
|
363
|
+
revision: str = $\{repr(up_revision)}
|
|
364
|
+
down_revision: Union[str, None] = $\{repr(down_revision)}
|
|
365
|
+
branch_labels: Union[str, Sequence[str], None] = $\{repr(branch_labels)}
|
|
366
|
+
depends_on: Union[str, Sequence[str], None] = $\{repr(depends_on)}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def upgrade() -> None:
|
|
370
|
+
$\{upgrades if upgrades else "pass"}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def downgrade() -> None:
|
|
374
|
+
$\{downgrades if downgrades else "pass"}
|
|
375
|
+
`;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Generate initial migration: pgvector extension + app_settings table
|
|
379
|
+
*/
|
|
380
|
+
export function generateInitialMigration(_packageName) {
|
|
381
|
+
return `"""Initial migration: pgvector extension and app_settings table.
|
|
382
|
+
|
|
383
|
+
Revision ID: 001
|
|
384
|
+
Revises: None
|
|
385
|
+
Create Date: 2024-01-01 00:00:00.000000
|
|
386
|
+
|
|
387
|
+
# popeye:requires_extension=vector
|
|
388
|
+
"""
|
|
389
|
+
from typing import Sequence, Union
|
|
390
|
+
|
|
391
|
+
from alembic import op
|
|
392
|
+
import sqlalchemy as sa
|
|
393
|
+
|
|
394
|
+
revision: str = "001"
|
|
395
|
+
down_revision: Union[str, None] = None
|
|
396
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
397
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def upgrade() -> None:
|
|
401
|
+
# Enable pgvector extension (requires superuser or rds_superuser on managed DBs)
|
|
402
|
+
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
|
403
|
+
|
|
404
|
+
op.create_table(
|
|
405
|
+
"app_settings",
|
|
406
|
+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
407
|
+
sa.Column("key", sa.String(length=255), nullable=False),
|
|
408
|
+
sa.Column("value", sa.Text(), nullable=False),
|
|
409
|
+
sa.Column(
|
|
410
|
+
"created_at",
|
|
411
|
+
sa.DateTime(timezone=True),
|
|
412
|
+
server_default=sa.func.now(),
|
|
413
|
+
nullable=False,
|
|
414
|
+
),
|
|
415
|
+
sa.Column(
|
|
416
|
+
"updated_at",
|
|
417
|
+
sa.DateTime(timezone=True),
|
|
418
|
+
server_default=sa.func.now(),
|
|
419
|
+
nullable=False,
|
|
420
|
+
),
|
|
421
|
+
sa.PrimaryKeyConstraint("id"),
|
|
422
|
+
sa.UniqueConstraint("key"),
|
|
423
|
+
)
|
|
424
|
+
op.create_index("ix_app_settings_key", "app_settings", ["key"])
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def downgrade() -> None:
|
|
428
|
+
op.drop_index("ix_app_settings_key", table_name="app_settings")
|
|
429
|
+
op.drop_table("app_settings")
|
|
430
|
+
op.execute("DROP EXTENSION IF EXISTS vector")
|
|
431
|
+
`;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Generate pgvector helper utilities
|
|
435
|
+
*/
|
|
436
|
+
export function generateDbVectorHelpers(_packageName) {
|
|
437
|
+
return `"""
|
|
438
|
+
pgvector helper utilities.
|
|
439
|
+
|
|
440
|
+
Provides vector column type, similarity search, and sanity checks.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
import logging
|
|
444
|
+
from sqlalchemy import Column, text
|
|
445
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
446
|
+
|
|
447
|
+
logger = logging.getLogger(__name__)
|
|
448
|
+
|
|
449
|
+
# Reason: pgvector types are registered at import time by the pgvector package
|
|
450
|
+
try:
|
|
451
|
+
from pgvector.sqlalchemy import Vector
|
|
452
|
+
except ImportError:
|
|
453
|
+
Vector = None
|
|
454
|
+
logger.warning("pgvector package not installed - vector features unavailable")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def vector_column(dimensions: int = 1536, nullable: bool = True) -> Column:
|
|
458
|
+
"""
|
|
459
|
+
Create a pgvector column with the specified dimensions.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
dimensions: Vector dimensionality (default: 1536 for OpenAI embeddings).
|
|
463
|
+
nullable: Whether the column allows NULL values.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
Column: SQLAlchemy column with Vector type.
|
|
467
|
+
|
|
468
|
+
Raises:
|
|
469
|
+
ImportError: If pgvector package is not installed.
|
|
470
|
+
"""
|
|
471
|
+
if Vector is None:
|
|
472
|
+
raise ImportError("pgvector package is required for vector columns")
|
|
473
|
+
return Column(Vector(dimensions), nullable=nullable)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
async def cosine_similarity_search(
|
|
477
|
+
session: AsyncSession,
|
|
478
|
+
table_name: str,
|
|
479
|
+
column_name: str,
|
|
480
|
+
query_vector: list[float],
|
|
481
|
+
limit: int = 10,
|
|
482
|
+
) -> list[dict]:
|
|
483
|
+
"""
|
|
484
|
+
Perform cosine similarity search against a vector column.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
session: Async database session.
|
|
488
|
+
table_name: Name of the table to search.
|
|
489
|
+
column_name: Name of the vector column.
|
|
490
|
+
query_vector: Query embedding vector.
|
|
491
|
+
limit: Maximum number of results.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
list[dict]: Results with id and similarity score.
|
|
495
|
+
"""
|
|
496
|
+
vector_str = "[" + ",".join(str(v) for v in query_vector) + "]"
|
|
497
|
+
sql = text(
|
|
498
|
+
f"SELECT id, 1 - ({column_name} <=> :vec::vector) AS similarity "
|
|
499
|
+
f"FROM {table_name} "
|
|
500
|
+
f"ORDER BY {column_name} <=> :vec::vector "
|
|
501
|
+
f"LIMIT :lim"
|
|
502
|
+
)
|
|
503
|
+
result = await session.execute(sql, {"vec": vector_str, "lim": limit})
|
|
504
|
+
return [{"id": row[0], "similarity": row[1]} for row in result.fetchall()]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
async def check_vector_extension(session: AsyncSession) -> bool:
|
|
508
|
+
"""
|
|
509
|
+
Verify that the pgvector extension is installed and functional.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
session: Async database session.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
bool: True if pgvector is available.
|
|
516
|
+
"""
|
|
517
|
+
try:
|
|
518
|
+
result = await session.execute(
|
|
519
|
+
text("SELECT extname FROM pg_extension WHERE extname = 'vector'")
|
|
520
|
+
)
|
|
521
|
+
return result.scalar() is not None
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.error(f"Vector extension check failed: {e}")
|
|
524
|
+
return False
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Generate graceful startup hook
|
|
529
|
+
*/
|
|
530
|
+
export function generateDbStartupHook(_packageName) {
|
|
531
|
+
return `"""
|
|
532
|
+
Application startup hook.
|
|
533
|
+
|
|
534
|
+
Handles graceful startup when DATABASE_URL is not configured.
|
|
535
|
+
The app runs in limited mode without a database connection.
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
import logging
|
|
539
|
+
import os
|
|
540
|
+
|
|
541
|
+
logger = logging.getLogger(__name__)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def on_startup() -> None:
|
|
545
|
+
"""
|
|
546
|
+
Run startup checks for database connectivity.
|
|
547
|
+
|
|
548
|
+
If DATABASE_URL is not set, logs a warning and skips DB initialization.
|
|
549
|
+
The application continues to run in limited mode.
|
|
550
|
+
"""
|
|
551
|
+
database_url = os.getenv("DATABASE_URL", "")
|
|
552
|
+
|
|
553
|
+
if not database_url:
|
|
554
|
+
logger.warning(
|
|
555
|
+
"DATABASE_URL is not set. "
|
|
556
|
+
"Application running in limited mode without database. "
|
|
557
|
+
"Set DATABASE_URL in .env or environment to enable full functionality."
|
|
558
|
+
)
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
logger.info("DATABASE_URL detected - initializing database connection")
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
from .database.connection import check_db_connection
|
|
565
|
+
|
|
566
|
+
status = await check_db_connection()
|
|
567
|
+
if status.get("connected"):
|
|
568
|
+
logger.info("Database connection verified successfully")
|
|
569
|
+
else:
|
|
570
|
+
logger.error(
|
|
571
|
+
f"Database connection failed: {status.get('error', 'unknown')}"
|
|
572
|
+
)
|
|
573
|
+
except Exception as e:
|
|
574
|
+
logger.error(f"Database initialization error: {e}")
|
|
575
|
+
`;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Generate database health check route
|
|
579
|
+
*/
|
|
580
|
+
export function generateDbHealthRoute(_packageName) {
|
|
581
|
+
return `"""
|
|
582
|
+
Database health check endpoint.
|
|
583
|
+
|
|
584
|
+
Returns database connectivity and migration status.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
import logging
|
|
588
|
+
import os
|
|
589
|
+
|
|
590
|
+
from fastapi import APIRouter
|
|
591
|
+
from fastapi.responses import JSONResponse
|
|
592
|
+
from sqlalchemy import text
|
|
593
|
+
|
|
594
|
+
logger = logging.getLogger(__name__)
|
|
595
|
+
|
|
596
|
+
router = APIRouter(tags=["health"])
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@router.get("/health/db")
|
|
600
|
+
async def health_db():
|
|
601
|
+
"""
|
|
602
|
+
Database health check endpoint.
|
|
603
|
+
|
|
604
|
+
Returns:
|
|
605
|
+
JSONResponse: 200 with DB details if healthy, 503 if not ready.
|
|
606
|
+
"""
|
|
607
|
+
database_url = os.getenv("DATABASE_URL", "")
|
|
608
|
+
|
|
609
|
+
if not database_url:
|
|
610
|
+
return JSONResponse(
|
|
611
|
+
status_code=503,
|
|
612
|
+
content={
|
|
613
|
+
"status": "DB_NOT_READY",
|
|
614
|
+
"message": "DATABASE_URL not configured",
|
|
615
|
+
"setup_hint": "Set DATABASE_URL in .env or run the setup wizard",
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
try:
|
|
620
|
+
from .database.connection import engine
|
|
621
|
+
|
|
622
|
+
if engine is None:
|
|
623
|
+
return JSONResponse(
|
|
624
|
+
status_code=503,
|
|
625
|
+
content={
|
|
626
|
+
"status": "DB_NOT_READY",
|
|
627
|
+
"message": "Database engine not initialized",
|
|
628
|
+
},
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
async with engine.connect() as conn:
|
|
632
|
+
# Check basic connectivity
|
|
633
|
+
await conn.execute(text("SELECT 1"))
|
|
634
|
+
|
|
635
|
+
# Check migration status via alembic_version table
|
|
636
|
+
migration_info = {"current_revision": None}
|
|
637
|
+
try:
|
|
638
|
+
result = await conn.execute(
|
|
639
|
+
text("SELECT version_num FROM alembic_version LIMIT 1")
|
|
640
|
+
)
|
|
641
|
+
row = result.first()
|
|
642
|
+
if row:
|
|
643
|
+
migration_info["current_revision"] = row[0]
|
|
644
|
+
except Exception:
|
|
645
|
+
migration_info["current_revision"] = "alembic_version table not found"
|
|
646
|
+
|
|
647
|
+
return JSONResponse(
|
|
648
|
+
status_code=200,
|
|
649
|
+
content={
|
|
650
|
+
"status": "healthy",
|
|
651
|
+
"database": "connected",
|
|
652
|
+
"migrations": migration_info,
|
|
653
|
+
},
|
|
654
|
+
)
|
|
655
|
+
except Exception as e:
|
|
656
|
+
logger.error(f"Database health check failed: {e}")
|
|
657
|
+
return JSONResponse(
|
|
658
|
+
status_code=503,
|
|
659
|
+
content={
|
|
660
|
+
"status": "DB_NOT_READY",
|
|
661
|
+
"message": f"Database connection failed: {str(e)}",
|
|
662
|
+
},
|
|
663
|
+
)
|
|
664
|
+
`;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Generate test DB fixtures (conftest_db.py)
|
|
668
|
+
*/
|
|
669
|
+
export function generateDbConftest(packageName) {
|
|
670
|
+
return `"""
|
|
671
|
+
Database test fixtures.
|
|
672
|
+
|
|
673
|
+
Provides test database URL override and async session fixture.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
import os
|
|
677
|
+
import pytest
|
|
678
|
+
import pytest_asyncio
|
|
679
|
+
from sqlalchemy.ext.asyncio import (
|
|
680
|
+
AsyncSession,
|
|
681
|
+
async_sessionmaker,
|
|
682
|
+
create_async_engine,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
from src.${packageName}.database.models import Base
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# Override DATABASE_URL for tests
|
|
689
|
+
TEST_DATABASE_URL = os.getenv(
|
|
690
|
+
"TEST_DATABASE_URL",
|
|
691
|
+
"postgresql+asyncpg://postgres:postgres@localhost:5432/test_db",
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@pytest.fixture(scope="session")
|
|
696
|
+
def test_engine():
|
|
697
|
+
"""Create a test database engine."""
|
|
698
|
+
return create_async_engine(TEST_DATABASE_URL, echo=True)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@pytest_asyncio.fixture
|
|
702
|
+
async def db_session(test_engine) -> AsyncSession:
|
|
703
|
+
"""
|
|
704
|
+
Provide an async database session for tests.
|
|
705
|
+
|
|
706
|
+
Creates tables before tests and drops them after.
|
|
707
|
+
Each test gets a fresh transaction that is rolled back.
|
|
708
|
+
"""
|
|
709
|
+
async with test_engine.begin() as conn:
|
|
710
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
711
|
+
|
|
712
|
+
session_factory = async_sessionmaker(
|
|
713
|
+
test_engine, class_=AsyncSession, expire_on_commit=False
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
async with session_factory() as session:
|
|
717
|
+
yield session
|
|
718
|
+
|
|
719
|
+
async with test_engine.begin() as conn:
|
|
720
|
+
await conn.run_sync(Base.metadata.drop_all)
|
|
721
|
+
`;
|
|
722
|
+
}
|
|
723
|
+
//# sourceMappingURL=database-python.js.map
|