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.

Files changed (98) hide show
  1. rem/__init__.py +129 -2
  2. rem/agentic/README.md +76 -0
  3. rem/agentic/__init__.py +15 -0
  4. rem/agentic/agents/__init__.py +16 -2
  5. rem/agentic/agents/sse_simulator.py +500 -0
  6. rem/agentic/context.py +28 -22
  7. rem/agentic/llm_provider_models.py +301 -0
  8. rem/agentic/otel/setup.py +92 -4
  9. rem/agentic/providers/phoenix.py +32 -43
  10. rem/agentic/providers/pydantic_ai.py +142 -22
  11. rem/agentic/schema.py +358 -21
  12. rem/agentic/tools/rem_tools.py +3 -3
  13. rem/api/README.md +238 -1
  14. rem/api/deps.py +255 -0
  15. rem/api/main.py +151 -37
  16. rem/api/mcp_router/resources.py +1 -1
  17. rem/api/mcp_router/server.py +17 -2
  18. rem/api/mcp_router/tools.py +143 -7
  19. rem/api/middleware/tracking.py +172 -0
  20. rem/api/routers/admin.py +277 -0
  21. rem/api/routers/auth.py +124 -0
  22. rem/api/routers/chat/completions.py +152 -16
  23. rem/api/routers/chat/models.py +7 -3
  24. rem/api/routers/chat/sse_events.py +526 -0
  25. rem/api/routers/chat/streaming.py +608 -45
  26. rem/api/routers/dev.py +81 -0
  27. rem/api/routers/feedback.py +148 -0
  28. rem/api/routers/messages.py +473 -0
  29. rem/api/routers/models.py +78 -0
  30. rem/api/routers/query.py +357 -0
  31. rem/api/routers/shared_sessions.py +406 -0
  32. rem/auth/middleware.py +126 -27
  33. rem/cli/commands/README.md +201 -70
  34. rem/cli/commands/ask.py +13 -10
  35. rem/cli/commands/cluster.py +1359 -0
  36. rem/cli/commands/configure.py +4 -3
  37. rem/cli/commands/db.py +350 -137
  38. rem/cli/commands/experiments.py +76 -72
  39. rem/cli/commands/process.py +22 -15
  40. rem/cli/commands/scaffold.py +47 -0
  41. rem/cli/commands/schema.py +95 -49
  42. rem/cli/main.py +29 -6
  43. rem/config.py +2 -2
  44. rem/models/core/core_model.py +7 -1
  45. rem/models/core/rem_query.py +5 -2
  46. rem/models/entities/__init__.py +21 -0
  47. rem/models/entities/domain_resource.py +38 -0
  48. rem/models/entities/feedback.py +123 -0
  49. rem/models/entities/message.py +30 -1
  50. rem/models/entities/session.py +83 -0
  51. rem/models/entities/shared_session.py +180 -0
  52. rem/models/entities/user.py +10 -3
  53. rem/registry.py +373 -0
  54. rem/schemas/agents/rem.yaml +7 -3
  55. rem/services/content/providers.py +94 -140
  56. rem/services/content/service.py +92 -20
  57. rem/services/dreaming/affinity_service.py +2 -16
  58. rem/services/dreaming/moment_service.py +2 -15
  59. rem/services/embeddings/api.py +24 -17
  60. rem/services/embeddings/worker.py +16 -16
  61. rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
  62. rem/services/phoenix/client.py +252 -19
  63. rem/services/postgres/README.md +159 -15
  64. rem/services/postgres/__init__.py +2 -1
  65. rem/services/postgres/diff_service.py +426 -0
  66. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  67. rem/services/postgres/repository.py +132 -0
  68. rem/services/postgres/schema_generator.py +86 -5
  69. rem/services/postgres/service.py +6 -6
  70. rem/services/rate_limit.py +113 -0
  71. rem/services/rem/README.md +14 -0
  72. rem/services/rem/parser.py +44 -9
  73. rem/services/rem/service.py +36 -2
  74. rem/services/session/compression.py +17 -1
  75. rem/services/session/reload.py +1 -1
  76. rem/services/user_service.py +98 -0
  77. rem/settings.py +169 -17
  78. rem/sql/background_indexes.sql +21 -16
  79. rem/sql/migrations/001_install.sql +231 -54
  80. rem/sql/migrations/002_install_models.sql +457 -393
  81. rem/sql/migrations/003_optional_extensions.sql +326 -0
  82. rem/utils/constants.py +97 -0
  83. rem/utils/date_utils.py +228 -0
  84. rem/utils/embeddings.py +17 -4
  85. rem/utils/files.py +167 -0
  86. rem/utils/mime_types.py +158 -0
  87. rem/utils/model_helpers.py +156 -1
  88. rem/utils/schema_loader.py +191 -35
  89. rem/utils/sql_types.py +3 -1
  90. rem/utils/vision.py +9 -14
  91. rem/workers/README.md +14 -14
  92. rem/workers/db_maintainer.py +74 -0
  93. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/METADATA +303 -164
  94. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/RECORD +96 -70
  95. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/WHEEL +1 -1
  96. rem/sql/002_install_models.sql +0 -1068
  97. rem/sql/install_models.sql +0 -1038
  98. {remdb-0.3.0.dist-info → remdb-0.3.114.dist-info}/entry_points.txt +0 -0
@@ -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 = 5050
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
- if Path.home() / "Library/Application Support/Claude":
435
- config_dir = Path.home() / "Library/Application Support/Claude"
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="Apply background indexes (HNSW for vectors)",
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
- By default, applies both install.sql and install_models.sql.
137
- Use flags to apply specific migrations.
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
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
- # Discover migrations from migrations/ directory
179
- migrations_dir = sql_dir / "migrations"
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
- # 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]
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 filename, description in migrations:
199
- file_path = sql_dir / filename
151
+ for file_path, description in migrations:
200
152
  if not file_path.exists():
201
- if filename == "install_models.sql":
202
- click.secho(f" {filename} not found", fg="red")
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 --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()
157
+ click.secho(" rem db schema generate", fg="yellow")
158
+ raise click.Abort()
210
159
 
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()
160
+ # Apply each migration
161
+ import psycopg
162
+ conn_str = settings.postgres.connection_string
163
+ total_time = 0.0
218
164
 
219
- try:
220
- # Apply migrations
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
- for filename, description in migrations:
225
- file_path = sql_dir / filename
226
- checksum = calculate_checksum(file_path)
168
+ sql_content = file_path.read_text(encoding="utf-8")
169
+ start_time = time.time()
227
170
 
228
- click.echo(f"Applying: {description} ({filename})")
229
- click.echo(f" Checksum: {checksum[:16]}...")
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
- success, output, exec_time = await run_sql_file_async(file_path, db)
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
- 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")
181
+ except Exception as e:
182
+ click.secho(f" Failed: {e}", fg="red")
259
183
  raise click.Abort()
260
184
 
261
- finally:
262
- await db.disconnect()
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="test-user", help="User ID for loaded data")
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
- logger.info(f"User ID: {user_id}")
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 (set to user_id for backward compat)
460
- row_data["user_id"] = user_id
461
- row_data["tenant_id"] = user_id
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)