remdb 0.3.0__py3-none-any.whl → 0.3.114__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.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/__init__.py +129 -2
- rem/agentic/README.md +76 -0
- rem/agentic/__init__.py +15 -0
- rem/agentic/agents/__init__.py +16 -2
- rem/agentic/agents/sse_simulator.py +500 -0
- rem/agentic/context.py +28 -22
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/otel/setup.py +92 -4
- rem/agentic/providers/phoenix.py +32 -43
- rem/agentic/providers/pydantic_ai.py +142 -22
- rem/agentic/schema.py +358 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/README.md +238 -1
- rem/api/deps.py +255 -0
- rem/api/main.py +151 -37
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +17 -2
- rem/api/mcp_router/tools.py +143 -7
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +277 -0
- rem/api/routers/auth.py +124 -0
- rem/api/routers/chat/completions.py +152 -16
- rem/api/routers/chat/models.py +7 -3
- rem/api/routers/chat/sse_events.py +526 -0
- rem/api/routers/chat/streaming.py +608 -45
- rem/api/routers/dev.py +81 -0
- rem/api/routers/feedback.py +148 -0
- rem/api/routers/messages.py +473 -0
- rem/api/routers/models.py +78 -0
- rem/api/routers/query.py +357 -0
- rem/api/routers/shared_sessions.py +406 -0
- rem/auth/middleware.py +126 -27
- rem/cli/commands/README.md +201 -70
- rem/cli/commands/ask.py +13 -10
- rem/cli/commands/cluster.py +1359 -0
- rem/cli/commands/configure.py +4 -3
- rem/cli/commands/db.py +350 -137
- rem/cli/commands/experiments.py +76 -72
- rem/cli/commands/process.py +22 -15
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +95 -49
- rem/cli/main.py +29 -6
- rem/config.py +2 -2
- rem/models/core/core_model.py +7 -1
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/__init__.py +21 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/message.py +30 -1
- rem/models/entities/session.py +83 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/user.py +10 -3
- rem/registry.py +373 -0
- rem/schemas/agents/rem.yaml +7 -3
- rem/services/content/providers.py +94 -140
- rem/services/content/service.py +92 -20
- rem/services/dreaming/affinity_service.py +2 -16
- rem/services/dreaming/moment_service.py +2 -15
- rem/services/embeddings/api.py +24 -17
- rem/services/embeddings/worker.py +16 -16
- rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
- rem/services/phoenix/client.py +252 -19
- rem/services/postgres/README.md +159 -15
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +426 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
- rem/services/postgres/repository.py +132 -0
- rem/services/postgres/schema_generator.py +86 -5
- rem/services/postgres/service.py +6 -6
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +14 -0
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/compression.py +17 -1
- rem/services/session/reload.py +1 -1
- rem/services/user_service.py +98 -0
- rem/settings.py +169 -17
- rem/sql/background_indexes.sql +21 -16
- rem/sql/migrations/001_install.sql +231 -54
- rem/sql/migrations/002_install_models.sql +457 -393
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/embeddings.py +17 -4
- rem/utils/files.py +167 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +156 -1
- rem/utils/schema_loader.py +191 -35
- rem/utils/sql_types.py +3 -1
- rem/utils/vision.py +9 -14
- rem/workers/README.md +14 -14
- rem/workers/db_maintainer.py +74 -0
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/METADATA +303 -164
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/RECORD +96 -70
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/WHEEL +1 -1
- rem/sql/002_install_models.sql +0 -1068
- rem/sql/install_models.sql +0 -1038
- {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/entry_points.txt +0 -0
rem/cli/commands/configure.py
CHANGED
|
@@ -49,7 +49,7 @@ def prompt_postgres_config(use_defaults: bool = False) -> dict:
|
|
|
49
49
|
|
|
50
50
|
# Default values
|
|
51
51
|
host = "localhost"
|
|
52
|
-
port =
|
|
52
|
+
port = 5051
|
|
53
53
|
database = "rem"
|
|
54
54
|
username = "rem"
|
|
55
55
|
password = "rem"
|
|
@@ -431,8 +431,9 @@ def configure_command(install: bool, claude_desktop: bool, show: bool, edit: boo
|
|
|
431
431
|
if os.name == "nt": # Windows
|
|
432
432
|
config_dir = Path.home() / "AppData/Roaming/Claude"
|
|
433
433
|
elif os.name == "posix":
|
|
434
|
-
|
|
435
|
-
|
|
434
|
+
macos_path = Path.home() / "Library/Application Support/Claude"
|
|
435
|
+
if macos_path.exists():
|
|
436
|
+
config_dir = macos_path
|
|
436
437
|
else:
|
|
437
438
|
config_dir = Path.home() / ".config/Claude"
|
|
438
439
|
else:
|
rem/cli/commands/db.py
CHANGED
|
@@ -98,168 +98,97 @@ def calculate_checksum(file_path: Path) -> str:
|
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
@click.command()
|
|
101
|
-
@click.option(
|
|
102
|
-
"--install",
|
|
103
|
-
"install_only",
|
|
104
|
-
is_flag=True,
|
|
105
|
-
help="Apply only install.sql (extensions and infrastructure)",
|
|
106
|
-
)
|
|
107
|
-
@click.option(
|
|
108
|
-
"--models", "models_only", is_flag=True, help="Apply only install_models.sql (entity tables)"
|
|
109
|
-
)
|
|
110
101
|
@click.option(
|
|
111
102
|
"--background-indexes",
|
|
112
103
|
is_flag=True,
|
|
113
|
-
help="
|
|
114
|
-
)
|
|
115
|
-
@click.option(
|
|
116
|
-
"--connection",
|
|
117
|
-
"-c",
|
|
118
|
-
help="PostgreSQL connection string (overrides environment)",
|
|
119
|
-
)
|
|
120
|
-
@click.option(
|
|
121
|
-
"--sql-dir",
|
|
122
|
-
type=click.Path(exists=True, path_type=Path),
|
|
123
|
-
default=None,
|
|
124
|
-
help="Directory containing SQL files (defaults to package SQL dir)",
|
|
104
|
+
help="Also apply background HNSW indexes (run after data load)",
|
|
125
105
|
)
|
|
126
|
-
def migrate(
|
|
127
|
-
install_only: bool,
|
|
128
|
-
models_only: bool,
|
|
129
|
-
background_indexes: bool,
|
|
130
|
-
connection: str | None,
|
|
131
|
-
sql_dir: Path | None,
|
|
132
|
-
):
|
|
106
|
+
def migrate(background_indexes: bool):
|
|
133
107
|
"""
|
|
134
|
-
Apply database migrations.
|
|
108
|
+
Apply standard database migrations (001_install + 002_install_models).
|
|
109
|
+
|
|
110
|
+
This is a convenience command for initial setup. It applies:
|
|
111
|
+
1. 001_install.sql - Core infrastructure (extensions, kv_store)
|
|
112
|
+
2. 002_install_models.sql - Entity tables from registered models
|
|
135
113
|
|
|
136
|
-
|
|
137
|
-
|
|
114
|
+
For incremental changes, use the diff-based workflow instead:
|
|
115
|
+
rem db schema generate # Regenerate from models
|
|
116
|
+
rem db diff # Check what changed
|
|
117
|
+
rem db apply <file> # Apply changes
|
|
138
118
|
|
|
139
119
|
Examples:
|
|
140
|
-
rem db migrate
|
|
141
|
-
rem db migrate --
|
|
142
|
-
rem db migrate --models # Entity tables only
|
|
143
|
-
rem db migrate --background-indexes # Background HNSW indexes
|
|
120
|
+
rem db migrate # Initial setup
|
|
121
|
+
rem db migrate --background-indexes # Include HNSW indexes
|
|
144
122
|
"""
|
|
145
|
-
asyncio.run(_migrate_async(
|
|
123
|
+
asyncio.run(_migrate_async(background_indexes))
|
|
146
124
|
|
|
147
125
|
|
|
148
|
-
async def _migrate_async(
|
|
149
|
-
install_only: bool,
|
|
150
|
-
models_only: bool,
|
|
151
|
-
background_indexes: bool,
|
|
152
|
-
connection: str | None,
|
|
153
|
-
sql_dir: Path | None,
|
|
154
|
-
):
|
|
126
|
+
async def _migrate_async(background_indexes: bool):
|
|
155
127
|
"""Async implementation of migrate command."""
|
|
156
|
-
from ...
|
|
157
|
-
# Find SQL directory - use package SQL if not specified
|
|
158
|
-
if sql_dir is None:
|
|
159
|
-
import importlib.resources
|
|
160
|
-
try:
|
|
161
|
-
# Python 3.9+
|
|
162
|
-
sql_ref = importlib.resources.files("rem") / "sql"
|
|
163
|
-
sql_dir = Path(str(sql_ref))
|
|
164
|
-
except AttributeError:
|
|
165
|
-
# Fallback: try to find sql dir relative to package
|
|
166
|
-
import rem
|
|
167
|
-
package_dir = Path(rem.__file__).parent.parent
|
|
168
|
-
sql_dir = package_dir / "sql"
|
|
169
|
-
if not sql_dir.exists():
|
|
170
|
-
# Last resort: current directory
|
|
171
|
-
sql_dir = Path("sql")
|
|
128
|
+
from ...settings import settings
|
|
172
129
|
|
|
130
|
+
click.echo()
|
|
173
131
|
click.echo("REM Database Migration")
|
|
174
132
|
click.echo("=" * 60)
|
|
133
|
+
|
|
134
|
+
# Find SQL directory
|
|
135
|
+
sql_dir = Path(settings.sql_dir)
|
|
136
|
+
migrations_dir = sql_dir / "migrations"
|
|
137
|
+
|
|
175
138
|
click.echo(f"SQL Directory: {sql_dir}")
|
|
176
139
|
click.echo()
|
|
177
140
|
|
|
178
|
-
#
|
|
179
|
-
|
|
141
|
+
# Standard migration files
|
|
142
|
+
migrations = [
|
|
143
|
+
(migrations_dir / "001_install.sql", "Core Infrastructure"),
|
|
144
|
+
(migrations_dir / "002_install_models.sql", "Entity Tables"),
|
|
145
|
+
]
|
|
180
146
|
|
|
181
147
|
if background_indexes:
|
|
182
|
-
|
|
183
|
-
migrations = [("background_indexes.sql", "Background Indexes")]
|
|
184
|
-
elif install_only or models_only:
|
|
185
|
-
# Find specific migration
|
|
186
|
-
target_prefix = "001" if install_only else "002"
|
|
187
|
-
migration_files = sorted(migrations_dir.glob(f"{target_prefix}_*.sql"))
|
|
188
|
-
if migration_files:
|
|
189
|
-
migrations = [(f"migrations/{f.name}", f.stem.replace("_", " ").title()) for f in migration_files]
|
|
190
|
-
else:
|
|
191
|
-
migrations = []
|
|
192
|
-
else:
|
|
193
|
-
# Default: discover and apply all migrations in sorted order
|
|
194
|
-
migration_files = sorted(migrations_dir.glob("*.sql"))
|
|
195
|
-
migrations = [(f"migrations/{f.name}", f.stem.replace("_", " ").title()) for f in migration_files]
|
|
148
|
+
migrations.append((sql_dir / "background_indexes.sql", "Background Indexes"))
|
|
196
149
|
|
|
197
150
|
# Check files exist
|
|
198
|
-
for
|
|
199
|
-
file_path = sql_dir / filename
|
|
151
|
+
for file_path, description in migrations:
|
|
200
152
|
if not file_path.exists():
|
|
201
|
-
|
|
202
|
-
|
|
153
|
+
click.secho(f"✗ {file_path.name} not found", fg="red")
|
|
154
|
+
if "002" in file_path.name:
|
|
203
155
|
click.echo()
|
|
204
156
|
click.secho("Generate it first with:", fg="yellow")
|
|
205
|
-
click.secho(" rem db schema generate
|
|
206
|
-
|
|
207
|
-
else:
|
|
208
|
-
click.secho(f"✗ {filename} not found", fg="red")
|
|
209
|
-
raise click.Abort()
|
|
157
|
+
click.secho(" rem db schema generate", fg="yellow")
|
|
158
|
+
raise click.Abort()
|
|
210
159
|
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
raise click.Abort()
|
|
216
|
-
|
|
217
|
-
await db.connect()
|
|
160
|
+
# Apply each migration
|
|
161
|
+
import psycopg
|
|
162
|
+
conn_str = settings.postgres.connection_string
|
|
163
|
+
total_time = 0.0
|
|
218
164
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
total_time = 0.0
|
|
222
|
-
all_success = True
|
|
165
|
+
for file_path, description in migrations:
|
|
166
|
+
click.echo(f"Applying: {description} ({file_path.name})")
|
|
223
167
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
checksum = calculate_checksum(file_path)
|
|
168
|
+
sql_content = file_path.read_text(encoding="utf-8")
|
|
169
|
+
start_time = time.time()
|
|
227
170
|
|
|
228
|
-
|
|
229
|
-
|
|
171
|
+
try:
|
|
172
|
+
with psycopg.connect(conn_str) as conn:
|
|
173
|
+
with conn.cursor() as cur:
|
|
174
|
+
cur.execute(sql_content)
|
|
175
|
+
conn.commit()
|
|
230
176
|
|
|
231
|
-
|
|
177
|
+
exec_time = (time.time() - start_time) * 1000
|
|
232
178
|
total_time += exec_time
|
|
179
|
+
click.secho(f" ✓ Applied in {exec_time:.0f}ms", fg="green")
|
|
233
180
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
# Show any NOTICE messages from the output
|
|
237
|
-
for line in output.split("\n"):
|
|
238
|
-
if "NOTICE:" in line or "✓" in line:
|
|
239
|
-
notice = line.split("NOTICE:")[-1].strip()
|
|
240
|
-
if notice:
|
|
241
|
-
click.echo(f" {notice}")
|
|
242
|
-
else:
|
|
243
|
-
click.secho(f" ✗ Failed", fg="red")
|
|
244
|
-
click.echo()
|
|
245
|
-
click.secho("Error output:", fg="red")
|
|
246
|
-
click.secho(output, fg="red")
|
|
247
|
-
all_success = False
|
|
248
|
-
break
|
|
249
|
-
|
|
250
|
-
click.echo()
|
|
251
|
-
|
|
252
|
-
# Summary
|
|
253
|
-
click.echo("=" * 60)
|
|
254
|
-
if all_success:
|
|
255
|
-
click.secho(f"✓ All migrations applied successfully", fg="green")
|
|
256
|
-
click.echo(f" Total time: {total_time:.0f}ms")
|
|
257
|
-
else:
|
|
258
|
-
click.secho(f"✗ Migration failed", fg="red")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
click.secho(f" ✗ Failed: {e}", fg="red")
|
|
259
183
|
raise click.Abort()
|
|
260
184
|
|
|
261
|
-
|
|
262
|
-
|
|
185
|
+
click.echo()
|
|
186
|
+
|
|
187
|
+
click.echo("=" * 60)
|
|
188
|
+
click.secho("✓ All migrations applied", fg="green")
|
|
189
|
+
click.echo(f" Total time: {total_time:.0f}ms")
|
|
190
|
+
click.echo()
|
|
191
|
+
click.echo("Next: verify with 'rem db diff'")
|
|
263
192
|
|
|
264
193
|
|
|
265
194
|
@click.command()
|
|
@@ -382,9 +311,9 @@ def rebuild_cache(connection: str | None):
|
|
|
382
311
|
|
|
383
312
|
@click.command()
|
|
384
313
|
@click.argument("file_path", type=click.Path(exists=True, path_type=Path))
|
|
385
|
-
@click.option("--user-id", default=
|
|
314
|
+
@click.option("--user-id", default=None, help="User ID to scope data privately (default: public/shared)")
|
|
386
315
|
@click.option("--dry-run", is_flag=True, help="Show what would be loaded without loading")
|
|
387
|
-
def load(file_path: Path, user_id: str, dry_run: bool):
|
|
316
|
+
def load(file_path: Path, user_id: str | None, dry_run: bool):
|
|
388
317
|
"""
|
|
389
318
|
Load data from YAML file into database.
|
|
390
319
|
|
|
@@ -397,21 +326,22 @@ def load(file_path: Path, user_id: str, dry_run: bool):
|
|
|
397
326
|
|
|
398
327
|
Examples:
|
|
399
328
|
rem db load rem/tests/data/graph_seed.yaml
|
|
400
|
-
rem db load data.yaml --user-id my-user
|
|
329
|
+
rem db load data.yaml --user-id my-user # Private to user
|
|
401
330
|
rem db load data.yaml --dry-run
|
|
402
331
|
"""
|
|
403
332
|
asyncio.run(_load_async(file_path, user_id, dry_run))
|
|
404
333
|
|
|
405
334
|
|
|
406
|
-
async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
335
|
+
async def _load_async(file_path: Path, user_id: str | None, dry_run: bool):
|
|
407
336
|
"""Async implementation of load command."""
|
|
408
337
|
import yaml
|
|
409
338
|
from ...models.core.inline_edge import InlineEdge
|
|
410
|
-
from ...models.entities import Resource, Moment, User
|
|
339
|
+
from ...models.entities import Resource, Moment, User, Message, SharedSession, Schema
|
|
411
340
|
from ...services.postgres import get_postgres_service
|
|
412
341
|
|
|
413
342
|
logger.info(f"Loading data from: {file_path}")
|
|
414
|
-
|
|
343
|
+
scope_msg = f"user: {user_id}" if user_id else "public"
|
|
344
|
+
logger.info(f"Scope: {scope_msg}")
|
|
415
345
|
|
|
416
346
|
# Load YAML file
|
|
417
347
|
with open(file_path) as f:
|
|
@@ -427,12 +357,18 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
427
357
|
return
|
|
428
358
|
|
|
429
359
|
# Map table names to model classes
|
|
360
|
+
# CoreModel subclasses use Repository.upsert()
|
|
430
361
|
MODEL_MAP = {
|
|
431
362
|
"users": User,
|
|
432
363
|
"moments": Moment,
|
|
433
364
|
"resources": Resource,
|
|
365
|
+
"messages": Message,
|
|
366
|
+
"schemas": Schema,
|
|
434
367
|
}
|
|
435
368
|
|
|
369
|
+
# Non-CoreModel tables that need direct SQL insertion
|
|
370
|
+
DIRECT_INSERT_TABLES = {"shared_sessions"}
|
|
371
|
+
|
|
436
372
|
# Connect to database
|
|
437
373
|
pg = get_postgres_service()
|
|
438
374
|
if not pg:
|
|
@@ -449,6 +385,29 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
449
385
|
key_field = table_def.get("key_field", "id")
|
|
450
386
|
rows = table_def.get("rows", [])
|
|
451
387
|
|
|
388
|
+
# Handle direct insert tables (non-CoreModel)
|
|
389
|
+
if table_name in DIRECT_INSERT_TABLES:
|
|
390
|
+
for row_data in rows:
|
|
391
|
+
# Add tenant_id if not present
|
|
392
|
+
if "tenant_id" not in row_data:
|
|
393
|
+
row_data["tenant_id"] = "default"
|
|
394
|
+
|
|
395
|
+
if table_name == "shared_sessions":
|
|
396
|
+
# Insert shared_session directly
|
|
397
|
+
await pg.fetch(
|
|
398
|
+
"""INSERT INTO shared_sessions
|
|
399
|
+
(session_id, owner_user_id, shared_with_user_id, tenant_id)
|
|
400
|
+
VALUES ($1, $2, $3, $4)
|
|
401
|
+
ON CONFLICT DO NOTHING""",
|
|
402
|
+
row_data["session_id"],
|
|
403
|
+
row_data["owner_user_id"],
|
|
404
|
+
row_data["shared_with_user_id"],
|
|
405
|
+
row_data["tenant_id"],
|
|
406
|
+
)
|
|
407
|
+
total_loaded += 1
|
|
408
|
+
logger.success(f"Loaded shared_session: {row_data['owner_user_id']} -> {row_data['shared_with_user_id']}")
|
|
409
|
+
continue
|
|
410
|
+
|
|
452
411
|
if table_name not in MODEL_MAP:
|
|
453
412
|
logger.warning(f"Unknown table: {table_name}, skipping")
|
|
454
413
|
continue
|
|
@@ -456,9 +415,13 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
456
415
|
model_class = MODEL_MAP[table_name] # Type is inferred from MODEL_MAP
|
|
457
416
|
|
|
458
417
|
for row_data in rows:
|
|
459
|
-
# Add user_id and tenant_id
|
|
460
|
-
|
|
461
|
-
|
|
418
|
+
# Add user_id and tenant_id only if explicitly provided
|
|
419
|
+
# Default is public (None) - data is shared/visible to all
|
|
420
|
+
# Pass --user-id to scope data privately to a specific user
|
|
421
|
+
if "user_id" not in row_data and user_id is not None:
|
|
422
|
+
row_data["user_id"] = user_id
|
|
423
|
+
if "tenant_id" not in row_data and user_id is not None:
|
|
424
|
+
row_data["tenant_id"] = row_data.get("user_id", user_id)
|
|
462
425
|
|
|
463
426
|
# Convert graph_edges to InlineEdge format if present
|
|
464
427
|
if "graph_edges" in row_data:
|
|
@@ -467,6 +430,16 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
467
430
|
for edge in row_data["graph_edges"]
|
|
468
431
|
]
|
|
469
432
|
|
|
433
|
+
# Convert any ISO timestamp strings with Z suffix to naive datetime
|
|
434
|
+
# This handles fields like starts_timestamp, ends_timestamp, etc.
|
|
435
|
+
from ...utils.date_utils import parse_iso
|
|
436
|
+
for key, value in list(row_data.items()):
|
|
437
|
+
if isinstance(value, str) and (key.endswith("_timestamp") or key.endswith("_at")):
|
|
438
|
+
try:
|
|
439
|
+
row_data[key] = parse_iso(value)
|
|
440
|
+
except (ValueError, TypeError):
|
|
441
|
+
pass # Not a valid datetime string, leave as-is
|
|
442
|
+
|
|
470
443
|
# Create model instance and upsert via repository
|
|
471
444
|
from ...services.postgres.repository import Repository
|
|
472
445
|
|
|
@@ -485,9 +458,249 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
485
458
|
await pg.disconnect()
|
|
486
459
|
|
|
487
460
|
|
|
461
|
+
@click.command()
|
|
462
|
+
@click.option(
|
|
463
|
+
"--check",
|
|
464
|
+
is_flag=True,
|
|
465
|
+
help="Exit with non-zero status if drift detected (for CI)",
|
|
466
|
+
)
|
|
467
|
+
@click.option(
|
|
468
|
+
"--generate",
|
|
469
|
+
is_flag=True,
|
|
470
|
+
help="Generate incremental migration file from diff",
|
|
471
|
+
)
|
|
472
|
+
@click.option(
|
|
473
|
+
"--models",
|
|
474
|
+
"-m",
|
|
475
|
+
type=click.Path(exists=True, path_type=Path),
|
|
476
|
+
default=None,
|
|
477
|
+
help="Directory containing Pydantic models (default: auto-detect)",
|
|
478
|
+
)
|
|
479
|
+
@click.option(
|
|
480
|
+
"--output-dir",
|
|
481
|
+
"-o",
|
|
482
|
+
type=click.Path(path_type=Path),
|
|
483
|
+
default=None,
|
|
484
|
+
help="Output directory for generated migration (default: sql/migrations)",
|
|
485
|
+
)
|
|
486
|
+
@click.option(
|
|
487
|
+
"--message",
|
|
488
|
+
default="schema_update",
|
|
489
|
+
help="Migration message/description (used in filename)",
|
|
490
|
+
)
|
|
491
|
+
def diff(
|
|
492
|
+
check: bool,
|
|
493
|
+
generate: bool,
|
|
494
|
+
models: Path | None,
|
|
495
|
+
output_dir: Path | None,
|
|
496
|
+
message: str,
|
|
497
|
+
):
|
|
498
|
+
"""
|
|
499
|
+
Compare database schema against Pydantic models.
|
|
500
|
+
|
|
501
|
+
Uses Alembic autogenerate to detect differences between:
|
|
502
|
+
- Your Pydantic models (the target schema)
|
|
503
|
+
- The current database (what's actually deployed)
|
|
504
|
+
|
|
505
|
+
Examples:
|
|
506
|
+
rem db diff # Show what would change
|
|
507
|
+
rem db diff --check # CI mode: exit 1 if drift detected
|
|
508
|
+
rem db diff --generate # Create migration file from diff
|
|
509
|
+
|
|
510
|
+
Workflow:
|
|
511
|
+
1. Develop locally, modify Pydantic models
|
|
512
|
+
2. Run 'rem db diff' to see changes
|
|
513
|
+
3. Run 'rem db diff --generate' to create migration
|
|
514
|
+
4. Review generated SQL, then 'rem db migrate'
|
|
515
|
+
"""
|
|
516
|
+
asyncio.run(_diff_async(check, generate, models, output_dir, message))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
async def _diff_async(
|
|
520
|
+
check: bool,
|
|
521
|
+
generate: bool,
|
|
522
|
+
models: Path | None,
|
|
523
|
+
output_dir: Path | None,
|
|
524
|
+
message: str,
|
|
525
|
+
):
|
|
526
|
+
"""Async implementation of diff command."""
|
|
527
|
+
from ...services.postgres.diff_service import DiffService
|
|
528
|
+
|
|
529
|
+
click.echo()
|
|
530
|
+
click.echo("REM Schema Diff")
|
|
531
|
+
click.echo("=" * 60)
|
|
532
|
+
|
|
533
|
+
# Initialize diff service
|
|
534
|
+
diff_service = DiffService(models_dir=models)
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
# Compute diff
|
|
538
|
+
click.echo("Comparing Pydantic models against database...")
|
|
539
|
+
click.echo()
|
|
540
|
+
|
|
541
|
+
result = diff_service.compute_diff()
|
|
542
|
+
|
|
543
|
+
if not result.has_changes:
|
|
544
|
+
click.secho("✓ No schema drift detected", fg="green")
|
|
545
|
+
click.echo(" Database matches Pydantic models")
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
# Show changes
|
|
549
|
+
click.secho(f"⚠ Schema drift detected: {result.change_count} change(s)", fg="yellow")
|
|
550
|
+
click.echo()
|
|
551
|
+
click.echo("Changes:")
|
|
552
|
+
for line in result.summary:
|
|
553
|
+
if line.startswith("+"):
|
|
554
|
+
click.secho(f" {line}", fg="green")
|
|
555
|
+
elif line.startswith("-"):
|
|
556
|
+
click.secho(f" {line}", fg="red")
|
|
557
|
+
elif line.startswith("~"):
|
|
558
|
+
click.secho(f" {line}", fg="yellow")
|
|
559
|
+
else:
|
|
560
|
+
click.echo(f" {line}")
|
|
561
|
+
click.echo()
|
|
562
|
+
|
|
563
|
+
# Generate migration if requested
|
|
564
|
+
if generate:
|
|
565
|
+
# Determine output directory
|
|
566
|
+
if output_dir is None:
|
|
567
|
+
import importlib.resources
|
|
568
|
+
try:
|
|
569
|
+
sql_ref = importlib.resources.files("rem") / "sql" / "migrations"
|
|
570
|
+
output_dir = Path(str(sql_ref))
|
|
571
|
+
except AttributeError:
|
|
572
|
+
import rem
|
|
573
|
+
package_dir = Path(rem.__file__).parent.parent
|
|
574
|
+
output_dir = package_dir / "sql" / "migrations"
|
|
575
|
+
|
|
576
|
+
click.echo(f"Generating migration to: {output_dir}")
|
|
577
|
+
migration_path = diff_service.generate_migration_file(output_dir, message)
|
|
578
|
+
|
|
579
|
+
if migration_path:
|
|
580
|
+
click.secho(f"✓ Migration generated: {migration_path.name}", fg="green")
|
|
581
|
+
click.echo()
|
|
582
|
+
click.echo("Next steps:")
|
|
583
|
+
click.echo(" 1. Review the generated SQL file")
|
|
584
|
+
click.echo(" 2. Run: rem db migrate")
|
|
585
|
+
else:
|
|
586
|
+
click.echo("No migration file generated (no changes)")
|
|
587
|
+
|
|
588
|
+
# CI check mode
|
|
589
|
+
if check:
|
|
590
|
+
click.echo()
|
|
591
|
+
click.secho("✗ Schema drift detected (--check mode)", fg="red")
|
|
592
|
+
raise SystemExit(1)
|
|
593
|
+
|
|
594
|
+
except SystemExit:
|
|
595
|
+
raise
|
|
596
|
+
except Exception as e:
|
|
597
|
+
click.secho(f"✗ Error: {e}", fg="red")
|
|
598
|
+
logger.exception("Diff failed")
|
|
599
|
+
raise click.Abort()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@click.command()
|
|
603
|
+
@click.argument("sql_file", type=click.Path(exists=True, path_type=Path))
|
|
604
|
+
@click.option(
|
|
605
|
+
"--log/--no-log",
|
|
606
|
+
default=True,
|
|
607
|
+
help="Log migration to rem_migrations table (default: yes)",
|
|
608
|
+
)
|
|
609
|
+
@click.option(
|
|
610
|
+
"--dry-run",
|
|
611
|
+
is_flag=True,
|
|
612
|
+
help="Show SQL that would be executed without running it",
|
|
613
|
+
)
|
|
614
|
+
def apply(sql_file: Path, log: bool, dry_run: bool):
|
|
615
|
+
"""
|
|
616
|
+
Apply a SQL file directly to the database.
|
|
617
|
+
|
|
618
|
+
This is the simple, code-as-source-of-truth approach:
|
|
619
|
+
- Pydantic models define the schema
|
|
620
|
+
- `rem db diff` detects drift
|
|
621
|
+
- `rem db diff --generate` creates migration SQL
|
|
622
|
+
- `rem db apply <file>` runs it
|
|
623
|
+
|
|
624
|
+
Examples:
|
|
625
|
+
rem db apply migrations/004_add_field.sql
|
|
626
|
+
rem db apply --dry-run migrations/004_add_field.sql
|
|
627
|
+
rem db apply --no-log migrations/004_add_field.sql
|
|
628
|
+
"""
|
|
629
|
+
asyncio.run(_apply_async(sql_file, log, dry_run))
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
async def _apply_async(sql_file: Path, log: bool, dry_run: bool):
|
|
633
|
+
"""Async implementation of apply command."""
|
|
634
|
+
from ...services.postgres import get_postgres_service
|
|
635
|
+
|
|
636
|
+
click.echo()
|
|
637
|
+
click.echo(f"Applying: {sql_file.name}")
|
|
638
|
+
click.echo("=" * 60)
|
|
639
|
+
|
|
640
|
+
# Read SQL content
|
|
641
|
+
sql_content = sql_file.read_text(encoding="utf-8")
|
|
642
|
+
|
|
643
|
+
if dry_run:
|
|
644
|
+
click.echo()
|
|
645
|
+
click.echo("SQL to execute (dry run):")
|
|
646
|
+
click.echo("-" * 40)
|
|
647
|
+
click.echo(sql_content)
|
|
648
|
+
click.echo("-" * 40)
|
|
649
|
+
click.echo()
|
|
650
|
+
click.secho("Dry run - no changes made", fg="yellow")
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# Execute SQL
|
|
654
|
+
db = get_postgres_service()
|
|
655
|
+
if not db:
|
|
656
|
+
click.secho("✗ Could not connect to database", fg="red")
|
|
657
|
+
raise click.Abort()
|
|
658
|
+
|
|
659
|
+
start_time = time.time()
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
import psycopg
|
|
663
|
+
from ...settings import settings
|
|
664
|
+
|
|
665
|
+
conn_str = settings.postgres.connection_string
|
|
666
|
+
|
|
667
|
+
with psycopg.connect(conn_str) as conn:
|
|
668
|
+
with conn.cursor() as cur:
|
|
669
|
+
cur.execute(sql_content)
|
|
670
|
+
conn.commit()
|
|
671
|
+
|
|
672
|
+
# Log to rem_migrations if requested
|
|
673
|
+
if log:
|
|
674
|
+
checksum = calculate_checksum(sql_file)
|
|
675
|
+
with conn.cursor() as cur:
|
|
676
|
+
cur.execute(
|
|
677
|
+
"""
|
|
678
|
+
INSERT INTO rem_migrations (name, type, checksum, applied_by)
|
|
679
|
+
VALUES (%s, 'diff', %s, CURRENT_USER)
|
|
680
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
681
|
+
applied_at = CURRENT_TIMESTAMP,
|
|
682
|
+
checksum = EXCLUDED.checksum
|
|
683
|
+
""",
|
|
684
|
+
(sql_file.name, checksum[:16]),
|
|
685
|
+
)
|
|
686
|
+
conn.commit()
|
|
687
|
+
|
|
688
|
+
execution_time = (time.time() - start_time) * 1000
|
|
689
|
+
click.secho(f"✓ Applied successfully in {execution_time:.0f}ms", fg="green")
|
|
690
|
+
|
|
691
|
+
if log:
|
|
692
|
+
click.echo(f" Logged to rem_migrations as '{sql_file.name}'")
|
|
693
|
+
|
|
694
|
+
except Exception as e:
|
|
695
|
+
click.secho(f"✗ Failed: {e}", fg="red")
|
|
696
|
+
raise click.Abort()
|
|
697
|
+
|
|
698
|
+
|
|
488
699
|
def register_commands(db_group):
|
|
489
700
|
"""Register all db commands."""
|
|
490
701
|
db_group.add_command(migrate)
|
|
491
702
|
db_group.add_command(status)
|
|
492
703
|
db_group.add_command(rebuild_cache, name="rebuild-cache")
|
|
493
704
|
db_group.add_command(load)
|
|
705
|
+
db_group.add_command(diff)
|
|
706
|
+
db_group.add_command(apply)
|