remdb 0.3.14__py3-none-any.whl → 0.3.157__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.
Files changed (112) hide show
  1. rem/agentic/README.md +76 -0
  2. rem/agentic/__init__.py +15 -0
  3. rem/agentic/agents/__init__.py +32 -2
  4. rem/agentic/agents/agent_manager.py +310 -0
  5. rem/agentic/agents/sse_simulator.py +502 -0
  6. rem/agentic/context.py +51 -27
  7. rem/agentic/context_builder.py +5 -3
  8. rem/agentic/llm_provider_models.py +301 -0
  9. rem/agentic/mcp/tool_wrapper.py +155 -18
  10. rem/agentic/otel/setup.py +93 -4
  11. rem/agentic/providers/phoenix.py +371 -108
  12. rem/agentic/providers/pydantic_ai.py +280 -57
  13. rem/agentic/schema.py +361 -21
  14. rem/agentic/tools/rem_tools.py +3 -3
  15. rem/api/README.md +215 -1
  16. rem/api/deps.py +255 -0
  17. rem/api/main.py +132 -40
  18. rem/api/mcp_router/resources.py +1 -1
  19. rem/api/mcp_router/server.py +28 -5
  20. rem/api/mcp_router/tools.py +555 -7
  21. rem/api/routers/admin.py +494 -0
  22. rem/api/routers/auth.py +278 -4
  23. rem/api/routers/chat/completions.py +402 -20
  24. rem/api/routers/chat/models.py +88 -10
  25. rem/api/routers/chat/otel_utils.py +33 -0
  26. rem/api/routers/chat/sse_events.py +542 -0
  27. rem/api/routers/chat/streaming.py +697 -45
  28. rem/api/routers/dev.py +81 -0
  29. rem/api/routers/feedback.py +268 -0
  30. rem/api/routers/messages.py +473 -0
  31. rem/api/routers/models.py +78 -0
  32. rem/api/routers/query.py +360 -0
  33. rem/api/routers/shared_sessions.py +406 -0
  34. rem/auth/__init__.py +13 -3
  35. rem/auth/middleware.py +186 -22
  36. rem/auth/providers/__init__.py +4 -1
  37. rem/auth/providers/email.py +215 -0
  38. rem/cli/commands/README.md +237 -64
  39. rem/cli/commands/cluster.py +1808 -0
  40. rem/cli/commands/configure.py +4 -7
  41. rem/cli/commands/db.py +386 -143
  42. rem/cli/commands/experiments.py +468 -76
  43. rem/cli/commands/process.py +14 -8
  44. rem/cli/commands/schema.py +97 -50
  45. rem/cli/commands/session.py +336 -0
  46. rem/cli/dreaming.py +2 -2
  47. rem/cli/main.py +29 -6
  48. rem/config.py +10 -3
  49. rem/models/core/core_model.py +7 -1
  50. rem/models/core/experiment.py +58 -14
  51. rem/models/core/rem_query.py +5 -2
  52. rem/models/entities/__init__.py +25 -0
  53. rem/models/entities/domain_resource.py +38 -0
  54. rem/models/entities/feedback.py +123 -0
  55. rem/models/entities/message.py +30 -1
  56. rem/models/entities/ontology.py +1 -1
  57. rem/models/entities/ontology_config.py +1 -1
  58. rem/models/entities/session.py +83 -0
  59. rem/models/entities/shared_session.py +180 -0
  60. rem/models/entities/subscriber.py +175 -0
  61. rem/models/entities/user.py +1 -0
  62. rem/registry.py +10 -4
  63. rem/schemas/agents/core/agent-builder.yaml +134 -0
  64. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  65. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  66. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  67. rem/schemas/agents/rem.yaml +7 -3
  68. rem/services/__init__.py +3 -1
  69. rem/services/content/service.py +92 -19
  70. rem/services/email/__init__.py +10 -0
  71. rem/services/email/service.py +459 -0
  72. rem/services/email/templates.py +360 -0
  73. rem/services/embeddings/api.py +4 -4
  74. rem/services/embeddings/worker.py +16 -16
  75. rem/services/phoenix/client.py +154 -14
  76. rem/services/postgres/README.md +197 -15
  77. rem/services/postgres/__init__.py +2 -1
  78. rem/services/postgres/diff_service.py +547 -0
  79. rem/services/postgres/pydantic_to_sqlalchemy.py +470 -140
  80. rem/services/postgres/repository.py +132 -0
  81. rem/services/postgres/schema_generator.py +205 -4
  82. rem/services/postgres/service.py +6 -6
  83. rem/services/rem/parser.py +44 -9
  84. rem/services/rem/service.py +36 -2
  85. rem/services/session/compression.py +137 -51
  86. rem/services/session/reload.py +15 -8
  87. rem/settings.py +515 -27
  88. rem/sql/background_indexes.sql +21 -16
  89. rem/sql/migrations/001_install.sql +387 -54
  90. rem/sql/migrations/002_install_models.sql +2304 -377
  91. rem/sql/migrations/003_optional_extensions.sql +326 -0
  92. rem/sql/migrations/004_cache_system.sql +548 -0
  93. rem/sql/migrations/005_schema_update.sql +145 -0
  94. rem/utils/README.md +45 -0
  95. rem/utils/__init__.py +18 -0
  96. rem/utils/date_utils.py +2 -2
  97. rem/utils/files.py +157 -1
  98. rem/utils/model_helpers.py +156 -1
  99. rem/utils/schema_loader.py +220 -22
  100. rem/utils/sql_paths.py +146 -0
  101. rem/utils/sql_types.py +3 -1
  102. rem/utils/vision.py +1 -1
  103. rem/workers/__init__.py +3 -1
  104. rem/workers/db_listener.py +579 -0
  105. rem/workers/unlogged_maintainer.py +463 -0
  106. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/METADATA +340 -229
  107. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/RECORD +109 -80
  108. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/WHEEL +1 -1
  109. rem/sql/002_install_models.sql +0 -1068
  110. rem/sql/install_models.sql +0 -1051
  111. rem/sql/migrations/003_seed_default_user.sql +0 -48
  112. {remdb-0.3.14.dist-info → remdb-0.3.157.dist-info}/entry_points.txt +0 -0
@@ -110,7 +110,7 @@ def prompt_llm_config(use_defaults: bool = False) -> dict:
110
110
  config = {}
111
111
 
112
112
  # Default values
113
- default_model = "anthropic:claude-sonnet-4-5-20250929"
113
+ default_model = "openai:gpt-4.1"
114
114
  default_temperature = 0.5
115
115
 
116
116
  if use_defaults:
@@ -124,9 +124,9 @@ def prompt_llm_config(use_defaults: bool = False) -> dict:
124
124
  # Default model
125
125
  click.echo("\nDefault LLM model (format: provider:model-id)")
126
126
  click.echo("Examples:")
127
+ click.echo(" - openai:gpt-4.1")
127
128
  click.echo(" - anthropic:claude-sonnet-4-5-20250929")
128
- click.echo(" - openai:gpt-4o")
129
- click.echo(" - openai:gpt-4o-mini")
129
+ click.echo(" - openai:gpt-4.1-mini")
130
130
 
131
131
  config["default_model"] = click.prompt(
132
132
  "Default model", default=default_model
@@ -405,9 +405,7 @@ def configure_command(install: bool, claude_desktop: bool, show: bool, edit: boo
405
405
 
406
406
  # Create a context for the command and invoke it
407
407
  ctx = click.Context(migrate)
408
- ctx.invoke(migrate, install_only=False, models_only=False,
409
- background_indexes=False, connection=None,
410
- sql_dir=Path("rem/sql"))
408
+ ctx.invoke(migrate, background_indexes=False)
411
409
 
412
410
  click.echo("✅ Database installation complete")
413
411
 
@@ -424,7 +422,6 @@ def configure_command(install: bool, claude_desktop: bool, show: bool, edit: boo
424
422
 
425
423
  try:
426
424
  import shutil
427
- from pathlib import Path
428
425
  from fastmcp.mcp_config import update_config_file, StdioMCPServer
429
426
 
430
427
  # Find Claude Desktop config path
rem/cli/commands/db.py CHANGED
@@ -98,168 +98,119 @@ 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="Apply background indexes (HNSW for vectors)",
114
- )
115
- @click.option(
116
- "--connection",
117
- "-c",
118
- help="PostgreSQL connection string (overrides environment)",
104
+ help="Also apply background HNSW indexes (run after data load)",
119
105
  )
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)",
125
- )
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).
135
109
 
136
- By default, applies both install.sql and install_models.sql.
137
- Use flags to apply specific migrations.
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
113
+
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 # Apply all
141
- rem db migrate --install # Core infrastructure only
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(install_only, models_only, background_indexes, connection, sql_dir))
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 ...services.postgres import get_postgres_service
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
129
+ from ...utils.sql_paths import (
130
+ get_package_sql_dir,
131
+ get_user_sql_dir,
132
+ list_all_migrations,
133
+ )
172
134
 
135
+ click.echo()
173
136
  click.echo("REM Database Migration")
174
137
  click.echo("=" * 60)
175
- click.echo(f"SQL Directory: {sql_dir}")
138
+
139
+ # Find package SQL directory
140
+ try:
141
+ package_sql_dir = get_package_sql_dir()
142
+ click.echo(f"Package SQL: {package_sql_dir}")
143
+ except FileNotFoundError as e:
144
+ click.secho(f"✗ {e}", fg="red")
145
+ raise click.Abort()
146
+
147
+ # Check for user migrations
148
+ user_sql_dir = get_user_sql_dir()
149
+ if user_sql_dir:
150
+ click.echo(f"User SQL: {user_sql_dir}")
151
+
152
+ # Get all migrations (package + user)
153
+ all_migrations = list_all_migrations()
154
+
155
+ if not all_migrations:
156
+ click.secho("✗ No migration files found", fg="red")
157
+ raise click.Abort()
158
+
159
+ click.echo(f"Found {len(all_migrations)} migration(s)")
176
160
  click.echo()
177
161
 
178
- # Discover migrations from migrations/ directory
179
- migrations_dir = sql_dir / "migrations"
162
+ # Add background indexes if requested
163
+ migrations_to_apply = [(f, f.stem) for f in all_migrations]
180
164
 
181
165
  if background_indexes:
182
- # Special case: background indexes
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]
166
+ bg_indexes = package_sql_dir / "background_indexes.sql"
167
+ if bg_indexes.exists():
168
+ migrations_to_apply.append((bg_indexes, "Background Indexes"))
190
169
  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]
196
-
197
- # Check files exist
198
- for filename, description in migrations:
199
- file_path = sql_dir / filename
170
+ click.secho("⚠ background_indexes.sql not found, skipping", fg="yellow")
171
+
172
+ # Check all files exist (they should, but verify)
173
+ for file_path, description in migrations_to_apply:
200
174
  if not file_path.exists():
201
- if filename == "install_models.sql":
202
- click.secho(f" {filename} not found", fg="red")
175
+ click.secho(f"✗ {file_path.name} not found", fg="red")
176
+ if "002" in file_path.name:
203
177
  click.echo()
204
178
  click.secho("Generate it first with:", fg="yellow")
205
- click.secho(" rem db schema generate --models src/rem/models/entities", fg="yellow")
206
- raise click.Abort()
207
- else:
208
- click.secho(f"✗ {filename} not found", fg="red")
209
- raise click.Abort()
179
+ click.secho(" rem db schema generate", fg="yellow")
180
+ raise click.Abort()
210
181
 
211
- # Connect to database
212
- db = get_postgres_service()
213
- if not db:
214
- click.secho("Error: PostgreSQL is disabled in settings.", fg="red")
215
- raise click.Abort()
216
-
217
- await db.connect()
182
+ # Apply each migration
183
+ import psycopg
184
+ conn_str = settings.postgres.connection_string
185
+ total_time = 0.0
218
186
 
219
- try:
220
- # Apply migrations
221
- total_time = 0.0
222
- all_success = True
187
+ for file_path, description in migrations_to_apply:
188
+ click.echo(f"Applying: {file_path.name}")
223
189
 
224
- for filename, description in migrations:
225
- file_path = sql_dir / filename
226
- checksum = calculate_checksum(file_path)
190
+ sql_content = file_path.read_text(encoding="utf-8")
191
+ start_time = time.time()
227
192
 
228
- click.echo(f"Applying: {description} ({filename})")
229
- click.echo(f" Checksum: {checksum[:16]}...")
193
+ try:
194
+ with psycopg.connect(conn_str) as conn:
195
+ with conn.cursor() as cur:
196
+ cur.execute(sql_content)
197
+ conn.commit()
230
198
 
231
- success, output, exec_time = await run_sql_file_async(file_path, db)
199
+ exec_time = (time.time() - start_time) * 1000
232
200
  total_time += exec_time
201
+ click.secho(f" ✓ Applied in {exec_time:.0f}ms", fg="green")
233
202
 
234
- if success:
235
- click.secho(f" Applied in {exec_time:.0f}ms", fg="green")
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")
203
+ except Exception as e:
204
+ click.secho(f" Failed: {e}", fg="red")
259
205
  raise click.Abort()
260
206
 
261
- finally:
262
- await db.disconnect()
207
+ click.echo()
208
+
209
+ click.echo("=" * 60)
210
+ click.secho("✓ All migrations applied", fg="green")
211
+ click.echo(f" Total time: {total_time:.0f}ms")
212
+ click.echo()
213
+ click.echo("Next: verify with 'rem db diff'")
263
214
 
264
215
 
265
216
  @click.command()
@@ -382,7 +333,7 @@ def rebuild_cache(connection: str | None):
382
333
 
383
334
  @click.command()
384
335
  @click.argument("file_path", type=click.Path(exists=True, path_type=Path))
385
- @click.option("--user-id", default=None, help="User ID for loaded data (default: from settings)")
336
+ @click.option("--user-id", default=None, help="User ID to scope data privately (default: public/shared)")
386
337
  @click.option("--dry-run", is_flag=True, help="Show what would be loaded without loading")
387
338
  def load(file_path: Path, user_id: str | None, dry_run: bool):
388
339
  """
@@ -397,25 +348,22 @@ def load(file_path: Path, user_id: str | None, dry_run: bool):
397
348
 
398
349
  Examples:
399
350
  rem db load rem/tests/data/graph_seed.yaml
400
- rem db load data.yaml --user-id my-user
351
+ rem db load data.yaml --user-id my-user # Private to user
401
352
  rem db load data.yaml --dry-run
402
353
  """
403
- from ...settings import settings
354
+ asyncio.run(_load_async(file_path, user_id, dry_run))
404
355
 
405
- # Resolve user_id from settings if not provided
406
- effective_user_id = user_id or settings.test.effective_user_id
407
- asyncio.run(_load_async(file_path, effective_user_id, dry_run))
408
356
 
409
-
410
- async def _load_async(file_path: Path, user_id: str, dry_run: bool):
357
+ async def _load_async(file_path: Path, user_id: str | None, dry_run: bool):
411
358
  """Async implementation of load command."""
412
359
  import yaml
413
360
  from ...models.core.inline_edge import InlineEdge
414
- from ...models.entities import Resource, Moment, User
361
+ from ...models.entities import Resource, Moment, User, Message, SharedSession, Schema
415
362
  from ...services.postgres import get_postgres_service
416
363
 
417
364
  logger.info(f"Loading data from: {file_path}")
418
- logger.info(f"User ID: {user_id}")
365
+ scope_msg = f"user: {user_id}" if user_id else "public"
366
+ logger.info(f"Scope: {scope_msg}")
419
367
 
420
368
  # Load YAML file
421
369
  with open(file_path) as f:
@@ -431,12 +379,18 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
431
379
  return
432
380
 
433
381
  # Map table names to model classes
382
+ # CoreModel subclasses use Repository.upsert()
434
383
  MODEL_MAP = {
435
384
  "users": User,
436
385
  "moments": Moment,
437
386
  "resources": Resource,
387
+ "messages": Message,
388
+ "schemas": Schema,
438
389
  }
439
390
 
391
+ # Non-CoreModel tables that need direct SQL insertion
392
+ DIRECT_INSERT_TABLES = {"shared_sessions"}
393
+
440
394
  # Connect to database
441
395
  pg = get_postgres_service()
442
396
  if not pg:
@@ -453,6 +407,29 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
453
407
  key_field = table_def.get("key_field", "id")
454
408
  rows = table_def.get("rows", [])
455
409
 
410
+ # Handle direct insert tables (non-CoreModel)
411
+ if table_name in DIRECT_INSERT_TABLES:
412
+ for row_data in rows:
413
+ # Add tenant_id if not present
414
+ if "tenant_id" not in row_data:
415
+ row_data["tenant_id"] = "default"
416
+
417
+ if table_name == "shared_sessions":
418
+ # Insert shared_session directly
419
+ await pg.fetch(
420
+ """INSERT INTO shared_sessions
421
+ (session_id, owner_user_id, shared_with_user_id, tenant_id)
422
+ VALUES ($1, $2, $3, $4)
423
+ ON CONFLICT DO NOTHING""",
424
+ row_data["session_id"],
425
+ row_data["owner_user_id"],
426
+ row_data["shared_with_user_id"],
427
+ row_data["tenant_id"],
428
+ )
429
+ total_loaded += 1
430
+ logger.success(f"Loaded shared_session: {row_data['owner_user_id']} -> {row_data['shared_with_user_id']}")
431
+ continue
432
+
456
433
  if table_name not in MODEL_MAP:
457
434
  logger.warning(f"Unknown table: {table_name}, skipping")
458
435
  continue
@@ -460,9 +437,13 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
460
437
  model_class = MODEL_MAP[table_name] # Type is inferred from MODEL_MAP
461
438
 
462
439
  for row_data in rows:
463
- # Add user_id and tenant_id (set to user_id for backward compat)
464
- row_data["user_id"] = user_id
465
- row_data["tenant_id"] = user_id
440
+ # Add user_id and tenant_id only if explicitly provided
441
+ # Default is public (None) - data is shared/visible to all
442
+ # Pass --user-id to scope data privately to a specific user
443
+ if "user_id" not in row_data and user_id is not None:
444
+ row_data["user_id"] = user_id
445
+ if "tenant_id" not in row_data and user_id is not None:
446
+ row_data["tenant_id"] = row_data.get("user_id", user_id)
466
447
 
467
448
  # Convert graph_edges to InlineEdge format if present
468
449
  if "graph_edges" in row_data:
@@ -499,9 +480,271 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
499
480
  await pg.disconnect()
500
481
 
501
482
 
483
+ @click.command()
484
+ @click.option(
485
+ "--check",
486
+ is_flag=True,
487
+ help="Exit with non-zero status if drift detected (for CI)",
488
+ )
489
+ @click.option(
490
+ "--generate",
491
+ is_flag=True,
492
+ help="Generate incremental migration file from diff",
493
+ )
494
+ @click.option(
495
+ "--strategy",
496
+ "-s",
497
+ type=click.Choice(["additive", "full", "safe"]),
498
+ default="additive",
499
+ help="Migration strategy: additive (no drops, default), full (all changes), safe (additive + type widenings)",
500
+ )
501
+ @click.option(
502
+ "--models",
503
+ "-m",
504
+ type=click.Path(exists=True, path_type=Path),
505
+ default=None,
506
+ help="Directory containing Pydantic models (default: auto-detect)",
507
+ )
508
+ @click.option(
509
+ "--output-dir",
510
+ "-o",
511
+ type=click.Path(path_type=Path),
512
+ default=None,
513
+ help="Output directory for generated migration (default: sql/migrations)",
514
+ )
515
+ @click.option(
516
+ "--message",
517
+ default="schema_update",
518
+ help="Migration message/description (used in filename)",
519
+ )
520
+ def diff(
521
+ check: bool,
522
+ generate: bool,
523
+ strategy: str,
524
+ models: Path | None,
525
+ output_dir: Path | None,
526
+ message: str,
527
+ ):
528
+ """
529
+ Compare database schema against Pydantic models.
530
+
531
+ Uses Alembic autogenerate to detect differences between:
532
+ - Your Pydantic models (the target schema)
533
+ - The current database (what's actually deployed)
534
+
535
+ Strategies:
536
+ additive Only ADD columns/tables/indexes (safe, no data loss) [default]
537
+ full All changes including DROPs (use with caution)
538
+ safe Additive + safe column type changes (widenings only)
539
+
540
+ Examples:
541
+ rem db diff # Show additive changes only
542
+ rem db diff --strategy full # Show all changes including drops
543
+ rem db diff --generate # Create migration file
544
+ rem db diff --check # CI mode: exit 1 if drift
545
+
546
+ Workflow:
547
+ 1. Develop locally, modify Pydantic models
548
+ 2. Run 'rem db diff' to see changes
549
+ 3. Run 'rem db diff --generate' to create migration
550
+ 4. Review generated SQL, then 'rem db apply <file>'
551
+ """
552
+ asyncio.run(_diff_async(check, generate, strategy, models, output_dir, message))
553
+
554
+
555
+ async def _diff_async(
556
+ check: bool,
557
+ generate: bool,
558
+ strategy: str,
559
+ models: Path | None,
560
+ output_dir: Path | None,
561
+ message: str,
562
+ ):
563
+ """Async implementation of diff command."""
564
+ from ...services.postgres.diff_service import DiffService
565
+
566
+ click.echo()
567
+ click.echo("REM Schema Diff")
568
+ click.echo("=" * 60)
569
+ click.echo(f"Strategy: {strategy}")
570
+
571
+ # Initialize diff service
572
+ diff_service = DiffService(models_dir=models, strategy=strategy)
573
+
574
+ try:
575
+ # Compute diff
576
+ click.echo("Comparing Pydantic models against database...")
577
+ click.echo()
578
+
579
+ result = diff_service.compute_diff()
580
+
581
+ if not result.has_changes:
582
+ click.secho("✓ No schema drift detected", fg="green")
583
+ click.echo(" Database matches Pydantic models")
584
+ if result.filtered_count > 0:
585
+ click.echo()
586
+ click.secho(f" ({result.filtered_count} destructive change(s) hidden by '{strategy}' strategy)", fg="yellow")
587
+ click.echo(" Use --strategy full to see all changes")
588
+ return
589
+
590
+ # Show changes
591
+ click.secho(f"⚠ Schema drift detected: {result.change_count} change(s)", fg="yellow")
592
+ if result.filtered_count > 0:
593
+ click.secho(f" ({result.filtered_count} destructive change(s) hidden by '{strategy}' strategy)", fg="yellow")
594
+ click.echo()
595
+ click.echo("Changes:")
596
+ for line in result.summary:
597
+ if line.startswith("+"):
598
+ click.secho(f" {line}", fg="green")
599
+ elif line.startswith("-"):
600
+ click.secho(f" {line}", fg="red")
601
+ elif line.startswith("~"):
602
+ click.secho(f" {line}", fg="yellow")
603
+ else:
604
+ click.echo(f" {line}")
605
+ click.echo()
606
+
607
+ # Generate migration if requested
608
+ if generate:
609
+ # Determine output directory
610
+ if output_dir is None:
611
+ import importlib.resources
612
+ try:
613
+ sql_ref = importlib.resources.files("rem") / "sql" / "migrations"
614
+ output_dir = Path(str(sql_ref))
615
+ except AttributeError:
616
+ import rem
617
+ package_dir = Path(rem.__file__).parent.parent
618
+ output_dir = package_dir / "sql" / "migrations"
619
+
620
+ click.echo(f"Generating migration to: {output_dir}")
621
+ migration_path = diff_service.generate_migration_file(output_dir, message)
622
+
623
+ if migration_path:
624
+ click.secho(f"✓ Migration generated: {migration_path.name}", fg="green")
625
+ click.echo()
626
+ click.echo("Next steps:")
627
+ click.echo(" 1. Review the generated SQL file")
628
+ click.echo(" 2. Run: rem db apply <file>")
629
+ else:
630
+ click.echo("No migration file generated (no changes)")
631
+
632
+ # CI check mode
633
+ if check:
634
+ click.echo()
635
+ click.secho("✗ Schema drift detected (--check mode)", fg="red")
636
+ raise SystemExit(1)
637
+
638
+ except SystemExit:
639
+ raise
640
+ except Exception as e:
641
+ click.secho(f"✗ Error: {e}", fg="red")
642
+ logger.exception("Diff failed")
643
+ raise click.Abort()
644
+
645
+
646
+ @click.command()
647
+ @click.argument("sql_file", type=click.Path(exists=True, path_type=Path))
648
+ @click.option(
649
+ "--log/--no-log",
650
+ default=True,
651
+ help="Log migration to rem_migrations table (default: yes)",
652
+ )
653
+ @click.option(
654
+ "--dry-run",
655
+ is_flag=True,
656
+ help="Show SQL that would be executed without running it",
657
+ )
658
+ def apply(sql_file: Path, log: bool, dry_run: bool):
659
+ """
660
+ Apply a SQL file directly to the database.
661
+
662
+ This is the simple, code-as-source-of-truth approach:
663
+ - Pydantic models define the schema
664
+ - `rem db diff` detects drift
665
+ - `rem db diff --generate` creates migration SQL
666
+ - `rem db apply <file>` runs it
667
+
668
+ Examples:
669
+ rem db apply migrations/004_add_field.sql
670
+ rem db apply --dry-run migrations/004_add_field.sql
671
+ rem db apply --no-log migrations/004_add_field.sql
672
+ """
673
+ asyncio.run(_apply_async(sql_file, log, dry_run))
674
+
675
+
676
+ async def _apply_async(sql_file: Path, log: bool, dry_run: bool):
677
+ """Async implementation of apply command."""
678
+ from ...services.postgres import get_postgres_service
679
+
680
+ click.echo()
681
+ click.echo(f"Applying: {sql_file.name}")
682
+ click.echo("=" * 60)
683
+
684
+ # Read SQL content
685
+ sql_content = sql_file.read_text(encoding="utf-8")
686
+
687
+ if dry_run:
688
+ click.echo()
689
+ click.echo("SQL to execute (dry run):")
690
+ click.echo("-" * 40)
691
+ click.echo(sql_content)
692
+ click.echo("-" * 40)
693
+ click.echo()
694
+ click.secho("Dry run - no changes made", fg="yellow")
695
+ return
696
+
697
+ # Execute SQL
698
+ db = get_postgres_service()
699
+ if not db:
700
+ click.secho("✗ Could not connect to database", fg="red")
701
+ raise click.Abort()
702
+
703
+ start_time = time.time()
704
+
705
+ try:
706
+ import psycopg
707
+ from ...settings import settings
708
+
709
+ conn_str = settings.postgres.connection_string
710
+
711
+ with psycopg.connect(conn_str) as conn:
712
+ with conn.cursor() as cur:
713
+ cur.execute(sql_content)
714
+ conn.commit()
715
+
716
+ # Log to rem_migrations if requested
717
+ if log:
718
+ checksum = calculate_checksum(sql_file)
719
+ with conn.cursor() as cur:
720
+ cur.execute(
721
+ """
722
+ INSERT INTO rem_migrations (name, type, checksum, applied_by)
723
+ VALUES (%s, 'diff', %s, CURRENT_USER)
724
+ ON CONFLICT (name) DO UPDATE SET
725
+ applied_at = CURRENT_TIMESTAMP,
726
+ checksum = EXCLUDED.checksum
727
+ """,
728
+ (sql_file.name, checksum[:16]),
729
+ )
730
+ conn.commit()
731
+
732
+ execution_time = (time.time() - start_time) * 1000
733
+ click.secho(f"✓ Applied successfully in {execution_time:.0f}ms", fg="green")
734
+
735
+ if log:
736
+ click.echo(f" Logged to rem_migrations as '{sql_file.name}'")
737
+
738
+ except Exception as e:
739
+ click.secho(f"✗ Failed: {e}", fg="red")
740
+ raise click.Abort()
741
+
742
+
502
743
  def register_commands(db_group):
503
744
  """Register all db commands."""
504
745
  db_group.add_command(migrate)
505
746
  db_group.add_command(status)
506
747
  db_group.add_command(rebuild_cache, name="rebuild-cache")
507
748
  db_group.add_command(load)
749
+ db_group.add_command(diff)
750
+ db_group.add_command(apply)