remdb 0.3.103__py3-none-any.whl → 0.3.118__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/agentic/context.py +28 -24
- rem/agentic/mcp/tool_wrapper.py +29 -3
- rem/agentic/otel/setup.py +92 -4
- rem/agentic/providers/pydantic_ai.py +88 -18
- rem/agentic/schema.py +358 -21
- rem/agentic/tools/rem_tools.py +3 -3
- rem/api/main.py +85 -16
- rem/api/mcp_router/resources.py +1 -1
- rem/api/mcp_router/server.py +18 -4
- rem/api/mcp_router/tools.py +383 -16
- rem/api/routers/admin.py +218 -1
- rem/api/routers/chat/completions.py +30 -3
- rem/api/routers/chat/streaming.py +143 -3
- rem/api/routers/feedback.py +12 -319
- rem/api/routers/query.py +360 -0
- rem/api/routers/shared_sessions.py +13 -13
- rem/cli/commands/README.md +237 -64
- rem/cli/commands/cluster.py +1300 -0
- rem/cli/commands/configure.py +1 -3
- rem/cli/commands/db.py +354 -143
- rem/cli/commands/process.py +14 -8
- rem/cli/commands/schema.py +92 -45
- rem/cli/main.py +27 -6
- rem/models/core/rem_query.py +5 -2
- rem/models/entities/shared_session.py +2 -28
- rem/registry.py +10 -4
- rem/services/content/service.py +30 -8
- rem/services/embeddings/api.py +4 -4
- rem/services/embeddings/worker.py +16 -16
- rem/services/postgres/README.md +151 -26
- rem/services/postgres/__init__.py +2 -1
- rem/services/postgres/diff_service.py +531 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
- rem/services/postgres/schema_generator.py +205 -4
- rem/services/postgres/service.py +6 -6
- rem/services/rem/parser.py +44 -9
- rem/services/rem/service.py +36 -2
- rem/services/session/reload.py +1 -1
- rem/settings.py +56 -7
- rem/sql/background_indexes.sql +19 -24
- rem/sql/migrations/001_install.sql +252 -69
- rem/sql/migrations/002_install_models.sql +2171 -593
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +548 -0
- rem/utils/__init__.py +18 -0
- rem/utils/date_utils.py +2 -2
- rem/utils/schema_loader.py +17 -13
- rem/utils/sql_paths.py +146 -0
- rem/workers/__init__.py +2 -1
- rem/workers/unlogged_maintainer.py +463 -0
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/METADATA +149 -76
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/RECORD +54 -48
- rem/sql/migrations/003_seed_default_user.sql +0 -48
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/WHEEL +0 -0
- {remdb-0.3.103.dist-info → remdb-0.3.118.dist-info}/entry_points.txt +0 -0
rem/cli/commands/configure.py
CHANGED
|
@@ -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,
|
|
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
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
206
|
-
|
|
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
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
checksum = calculate_checksum(file_path)
|
|
190
|
+
sql_content = file_path.read_text(encoding="utf-8")
|
|
191
|
+
start_time = time.time()
|
|
227
192
|
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
203
|
+
except Exception as e:
|
|
204
|
+
click.secho(f" ✗ Failed: {e}", fg="red")
|
|
259
205
|
raise click.Abort()
|
|
260
206
|
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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,17 +348,13 @@ 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
|
-
|
|
404
|
-
|
|
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))
|
|
354
|
+
asyncio.run(_load_async(file_path, user_id, dry_run))
|
|
408
355
|
|
|
409
356
|
|
|
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
|
|
@@ -415,7 +362,8 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
415
362
|
from ...services.postgres import get_postgres_service
|
|
416
363
|
|
|
417
364
|
logger.info(f"Loading data from: {file_path}")
|
|
418
|
-
|
|
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:
|
|
@@ -489,11 +437,12 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
489
437
|
model_class = MODEL_MAP[table_name] # Type is inferred from MODEL_MAP
|
|
490
438
|
|
|
491
439
|
for row_data in rows:
|
|
492
|
-
# Add user_id and tenant_id if
|
|
493
|
-
#
|
|
494
|
-
|
|
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:
|
|
495
444
|
row_data["user_id"] = user_id
|
|
496
|
-
if "tenant_id" not in row_data:
|
|
445
|
+
if "tenant_id" not in row_data and user_id is not None:
|
|
497
446
|
row_data["tenant_id"] = row_data.get("user_id", user_id)
|
|
498
447
|
|
|
499
448
|
# Convert graph_edges to InlineEdge format if present
|
|
@@ -531,9 +480,271 @@ async def _load_async(file_path: Path, user_id: str, dry_run: bool):
|
|
|
531
480
|
await pg.disconnect()
|
|
532
481
|
|
|
533
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
|
+
|
|
534
743
|
def register_commands(db_group):
|
|
535
744
|
"""Register all db commands."""
|
|
536
745
|
db_group.add_command(migrate)
|
|
537
746
|
db_group.add_command(status)
|
|
538
747
|
db_group.add_command(rebuild_cache, name="rebuild-cache")
|
|
539
748
|
db_group.add_command(load)
|
|
749
|
+
db_group.add_command(diff)
|
|
750
|
+
db_group.add_command(apply)
|
rem/cli/commands/process.py
CHANGED
|
@@ -12,12 +12,12 @@ from rem.services.content import ContentService
|
|
|
12
12
|
|
|
13
13
|
@click.command(name="ingest")
|
|
14
14
|
@click.argument("file_path", type=click.Path(exists=True))
|
|
15
|
-
@click.option("--user-id",
|
|
15
|
+
@click.option("--user-id", default=None, help="User ID to scope file privately (default: public/shared)")
|
|
16
16
|
@click.option("--category", help="Optional file category")
|
|
17
17
|
@click.option("--tags", help="Optional comma-separated tags")
|
|
18
18
|
def process_ingest(
|
|
19
19
|
file_path: str,
|
|
20
|
-
user_id: str,
|
|
20
|
+
user_id: str | None,
|
|
21
21
|
category: str | None,
|
|
22
22
|
tags: str | None,
|
|
23
23
|
):
|
|
@@ -32,8 +32,9 @@ def process_ingest(
|
|
|
32
32
|
5. Creates a File entity record.
|
|
33
33
|
|
|
34
34
|
Examples:
|
|
35
|
-
rem process ingest sample.pdf
|
|
36
|
-
rem process ingest contract.docx --
|
|
35
|
+
rem process ingest sample.pdf
|
|
36
|
+
rem process ingest contract.docx --category legal --tags contract,2023
|
|
37
|
+
rem process ingest agent.yaml # Auto-detects kind=agent, saves to schemas table
|
|
37
38
|
"""
|
|
38
39
|
import asyncio
|
|
39
40
|
from ...services.content import ContentService
|
|
@@ -56,7 +57,8 @@ def process_ingest(
|
|
|
56
57
|
|
|
57
58
|
tag_list = tags.split(",") if tags else None
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
scope_msg = f"user: {user_id}" if user_id else "public"
|
|
61
|
+
logger.info(f"Ingesting file: {file_path} ({scope_msg})")
|
|
60
62
|
result = await service.ingest_file(
|
|
61
63
|
file_uri=file_path,
|
|
62
64
|
user_id=user_id,
|
|
@@ -65,11 +67,15 @@ def process_ingest(
|
|
|
65
67
|
is_local_server=True, # CLI is local
|
|
66
68
|
)
|
|
67
69
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
# Handle schema ingestion (agents/evaluators)
|
|
71
|
+
if result.get("schema_name"):
|
|
72
|
+
logger.success(f"Schema ingested: {result['schema_name']} (kind={result.get('kind', 'agent')})")
|
|
73
|
+
logger.info(f"Version: {result.get('version', '1.0.0')}")
|
|
74
|
+
# Handle file ingestion
|
|
75
|
+
elif result.get("processing_status") == "completed":
|
|
76
|
+
logger.success(f"File ingested: {result['file_name']}")
|
|
70
77
|
logger.info(f"File ID: {result['file_id']}")
|
|
71
78
|
logger.info(f"Resources created: {result['resources_created']}")
|
|
72
|
-
logger.info(f"Status: {result['processing_status']}")
|
|
73
79
|
else:
|
|
74
80
|
logger.error(f"Ingestion failed: {result.get('message', 'Unknown error')}")
|
|
75
81
|
sys.exit(1)
|