remdb 0.3.180__py3-none-any.whl → 0.3.258__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 (70) hide show
  1. rem/agentic/README.md +36 -2
  2. rem/agentic/__init__.py +10 -1
  3. rem/agentic/context.py +185 -1
  4. rem/agentic/context_builder.py +56 -35
  5. rem/agentic/mcp/tool_wrapper.py +2 -2
  6. rem/agentic/providers/pydantic_ai.py +303 -111
  7. rem/agentic/schema.py +2 -2
  8. rem/api/main.py +1 -1
  9. rem/api/mcp_router/resources.py +223 -0
  10. rem/api/mcp_router/server.py +4 -0
  11. rem/api/mcp_router/tools.py +608 -166
  12. rem/api/routers/admin.py +30 -4
  13. rem/api/routers/auth.py +219 -20
  14. rem/api/routers/chat/child_streaming.py +393 -0
  15. rem/api/routers/chat/completions.py +77 -40
  16. rem/api/routers/chat/sse_events.py +7 -3
  17. rem/api/routers/chat/streaming.py +381 -291
  18. rem/api/routers/chat/streaming_utils.py +325 -0
  19. rem/api/routers/common.py +18 -0
  20. rem/api/routers/dev.py +7 -1
  21. rem/api/routers/feedback.py +11 -3
  22. rem/api/routers/messages.py +176 -38
  23. rem/api/routers/models.py +9 -1
  24. rem/api/routers/query.py +17 -15
  25. rem/api/routers/shared_sessions.py +16 -0
  26. rem/auth/jwt.py +19 -4
  27. rem/auth/middleware.py +42 -28
  28. rem/cli/README.md +62 -0
  29. rem/cli/commands/ask.py +205 -114
  30. rem/cli/commands/db.py +55 -31
  31. rem/cli/commands/experiments.py +1 -1
  32. rem/cli/commands/process.py +179 -43
  33. rem/cli/commands/query.py +109 -0
  34. rem/cli/commands/session.py +117 -0
  35. rem/cli/main.py +2 -0
  36. rem/models/core/experiment.py +1 -1
  37. rem/models/entities/ontology.py +18 -20
  38. rem/models/entities/session.py +1 -0
  39. rem/schemas/agents/core/agent-builder.yaml +1 -1
  40. rem/schemas/agents/rem.yaml +1 -1
  41. rem/schemas/agents/test_orchestrator.yaml +42 -0
  42. rem/schemas/agents/test_structured_output.yaml +52 -0
  43. rem/services/content/providers.py +151 -49
  44. rem/services/content/service.py +18 -5
  45. rem/services/embeddings/worker.py +26 -12
  46. rem/services/postgres/__init__.py +28 -3
  47. rem/services/postgres/diff_service.py +57 -5
  48. rem/services/postgres/programmable_diff_service.py +635 -0
  49. rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
  50. rem/services/postgres/register_type.py +11 -10
  51. rem/services/postgres/repository.py +39 -28
  52. rem/services/postgres/schema_generator.py +5 -5
  53. rem/services/postgres/sql_builder.py +6 -5
  54. rem/services/rem/README.md +4 -3
  55. rem/services/rem/parser.py +7 -10
  56. rem/services/rem/service.py +47 -0
  57. rem/services/session/__init__.py +8 -1
  58. rem/services/session/compression.py +47 -5
  59. rem/services/session/pydantic_messages.py +310 -0
  60. rem/services/session/reload.py +2 -1
  61. rem/settings.py +92 -7
  62. rem/sql/migrations/001_install.sql +125 -7
  63. rem/sql/migrations/002_install_models.sql +159 -149
  64. rem/sql/migrations/004_cache_system.sql +10 -276
  65. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  66. rem/utils/schema_loader.py +180 -120
  67. {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/METADATA +7 -6
  68. {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/RECORD +70 -61
  69. {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/WHEEL +0 -0
  70. {remdb-0.3.180.dist-info → remdb-0.3.258.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,635 @@
1
+ """
2
+ Programmable object diff service for comparing functions, triggers, and views.
3
+
4
+ The schema diff service (Alembic-based) only compares tables/columns/indexes.
5
+ This service fills the gap by comparing programmable objects.
6
+
7
+ Problem solved:
8
+ - Functions/triggers/views defined with CREATE OR REPLACE in migration files
9
+ - Once migration is marked "applied", object changes aren't detected
10
+ - Database can drift from source SQL files
11
+
12
+ Solution:
13
+ - Extract object definitions from SQL files (source of truth)
14
+ - Compare against installed objects in database
15
+ - Generate sync SQL to update stale objects
16
+ """
17
+
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ import asyncpg
25
+ from loguru import logger
26
+
27
+ from ...settings import settings
28
+
29
+
30
+ class ObjectType(str, Enum):
31
+ FUNCTION = "function"
32
+ TRIGGER = "trigger"
33
+ VIEW = "view"
34
+
35
+
36
+ @dataclass
37
+ class ObjectDiff:
38
+ """Difference detected for a single database object."""
39
+
40
+ name: str
41
+ object_type: ObjectType
42
+ status: str # "missing", "different", "extra"
43
+ source_def: Optional[str] = None
44
+ db_def: Optional[str] = None
45
+ # For triggers, track the table they're on
46
+ table_name: Optional[str] = None
47
+
48
+
49
+ @dataclass
50
+ class DiffResult:
51
+ """Result of object comparison."""
52
+
53
+ has_changes: bool
54
+ diffs: list[ObjectDiff] = field(default_factory=list)
55
+ sync_sql: str = ""
56
+
57
+ @property
58
+ def missing_count(self) -> int:
59
+ return sum(1 for d in self.diffs if d.status == "missing")
60
+
61
+ @property
62
+ def different_count(self) -> int:
63
+ return sum(1 for d in self.diffs if d.status == "different")
64
+
65
+ @property
66
+ def extra_count(self) -> int:
67
+ return sum(1 for d in self.diffs if d.status == "extra")
68
+
69
+ def by_type(self, obj_type: ObjectType) -> list[ObjectDiff]:
70
+ """Get diffs filtered by object type."""
71
+ return [d for d in self.diffs if d.object_type == obj_type]
72
+
73
+ def summary(self) -> str:
74
+ """Human-readable summary of differences."""
75
+ lines = []
76
+ for obj_type in ObjectType:
77
+ type_diffs = self.by_type(obj_type)
78
+ if type_diffs:
79
+ missing = sum(1 for d in type_diffs if d.status == "missing")
80
+ different = sum(1 for d in type_diffs if d.status == "different")
81
+ extra = sum(1 for d in type_diffs if d.status == "extra")
82
+ parts = []
83
+ if missing:
84
+ parts.append(f"{missing} missing")
85
+ if different:
86
+ parts.append(f"{different} different")
87
+ if extra:
88
+ parts.append(f"{extra} extra")
89
+ lines.append(f" {obj_type.value}s: {', '.join(parts)}")
90
+ return "\n".join(lines) if lines else " No differences found"
91
+
92
+
93
+ class ProgrammableDiffService:
94
+ """
95
+ Service for comparing SQL functions, triggers, and views between source files and database.
96
+
97
+ Usage:
98
+ service = ProgrammableDiffService()
99
+ result = await service.compute_diff()
100
+
101
+ if result.has_changes:
102
+ print(result.sync_sql) # SQL to sync objects
103
+ """
104
+
105
+ def __init__(self, sql_dir: Optional[Path] = None):
106
+ """
107
+ Initialize diff service.
108
+
109
+ Args:
110
+ sql_dir: Directory containing SQL files with object definitions.
111
+ Defaults to rem/sql/migrations/
112
+ """
113
+ if sql_dir is None:
114
+ import rem
115
+
116
+ sql_dir = Path(rem.__file__).parent / "sql" / "migrations"
117
+
118
+ self.sql_dir = sql_dir
119
+
120
+ # =========================================================================
121
+ # FUNCTION EXTRACTION AND COMPARISON
122
+ # =========================================================================
123
+
124
+ def _extract_functions_from_sql(self, sql_content: str) -> dict[str, str]:
125
+ """
126
+ Extract function definitions from SQL content.
127
+
128
+ Returns:
129
+ Dict mapping function name -> full CREATE OR REPLACE FUNCTION statement
130
+ """
131
+ functions = {}
132
+
133
+ # Pattern to match CREATE OR REPLACE FUNCTION ... $$ ... $$ LANGUAGE ...
134
+ # Handles both $function$ and $$ delimiters
135
+ # Captures through LANGUAGE clause and optional modifiers (STABLE, IMMUTABLE, etc.)
136
+ pattern = r"""
137
+ (CREATE\s+OR\s+REPLACE\s+FUNCTION\s+
138
+ (?:public\.)?(\w+)\s*\([^)]*\) # function name and params
139
+ .*? # return type etc
140
+ AS\s+\$(\w*)\$ # opening delimiter
141
+ .*? # function body
142
+ \$\3\$ # matching closing delimiter
143
+ \s*LANGUAGE\s+\w+ # LANGUAGE clause (required)
144
+ (?:\s+(?:STABLE|IMMUTABLE|VOLATILE|SECURITY\s+DEFINER|SECURITY\s+INVOKER))* # optional modifiers
145
+ )
146
+ """
147
+
148
+ for match in re.finditer(pattern, sql_content, re.DOTALL | re.IGNORECASE | re.VERBOSE):
149
+ full_def = match.group(1).strip()
150
+ func_name = match.group(2).lower()
151
+ functions[func_name] = full_def
152
+
153
+ return functions
154
+
155
+ async def get_db_functions(self, conn: asyncpg.Connection) -> dict[str, str]:
156
+ """Get all function definitions from the database."""
157
+ rows = await conn.fetch("""
158
+ SELECT
159
+ proname as name,
160
+ pg_get_functiondef(oid) as definition
161
+ FROM pg_proc
162
+ WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
163
+ AND prokind = 'f'
164
+ """)
165
+
166
+ return {row["name"]: row["definition"] for row in rows}
167
+
168
+ def _normalize_function_def(self, func_def: str) -> str:
169
+ """Normalize function definition for comparison."""
170
+ # Remove comments
171
+ func_def = re.sub(r"--.*$", "", func_def, flags=re.MULTILINE)
172
+
173
+ # Normalize whitespace
174
+ func_def = re.sub(r"\s+", " ", func_def).strip()
175
+
176
+ # Normalize case for keywords
177
+ keywords = [
178
+ "CREATE", "OR", "REPLACE", "FUNCTION", "RETURNS", "LANGUAGE",
179
+ "AS", "BEGIN", "END", "IF", "THEN", "ELSE", "ELSIF", "RETURN",
180
+ "DECLARE", "SELECT", "INSERT", "UPDATE", "DELETE", "FROM",
181
+ "WHERE", "INTO", "VALUES", "ON", "CONFLICT", "DO", "SET",
182
+ "NULL", "NOT", "AND", "TRUE", "FALSE", "COALESCE", "EXISTS",
183
+ "TRIGGER", "FOR", "EACH", "ROW", "EXECUTE", "PROCEDURE",
184
+ "IMMUTABLE", "STABLE", "VOLATILE", "PLPGSQL", "SQL",
185
+ ]
186
+ for kw in keywords:
187
+ func_def = re.sub(rf"\b{kw}\b", kw, func_def, flags=re.IGNORECASE)
188
+
189
+ return func_def
190
+
191
+ def _functions_match(self, source_def: str, db_def: str) -> bool:
192
+ """Compare two function definitions for equivalence."""
193
+ norm_source = self._normalize_function_def(source_def)
194
+ norm_db = self._normalize_function_def(db_def)
195
+
196
+ def extract_body(text: str) -> str:
197
+ match = re.search(r"\$\w*\$(.*)\$\w*\$", text, re.DOTALL)
198
+ if match:
199
+ return self._normalize_function_def(match.group(1))
200
+ return text
201
+
202
+ return extract_body(norm_source) == extract_body(norm_db)
203
+
204
+ # =========================================================================
205
+ # TRIGGER EXTRACTION AND COMPARISON
206
+ # =========================================================================
207
+
208
+ def _extract_triggers_from_sql(self, sql_content: str) -> dict[str, tuple[str, str]]:
209
+ """
210
+ Extract trigger definitions from SQL content.
211
+
212
+ Returns:
213
+ Dict mapping trigger name -> (full CREATE TRIGGER statement, table_name)
214
+ """
215
+ triggers = {}
216
+
217
+ # Pattern for CREATE TRIGGER (with optional OR REPLACE for pg14+)
218
+ # CREATE [OR REPLACE] TRIGGER name {BEFORE|AFTER|INSTEAD OF} event ON table ...
219
+ pattern = r"""
220
+ (CREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\s+
221
+ (\w+)\s+ # trigger name
222
+ (?:BEFORE|AFTER|INSTEAD\s+OF)\s+ # timing
223
+ (?:INSERT|UPDATE|DELETE|TRUNCATE) # first event
224
+ (?:\s+OR\s+(?:INSERT|UPDATE|DELETE|TRUNCATE))* # additional events
225
+ \s+ON\s+
226
+ (?:public\.)?(\w+) # table name
227
+ .*? # rest of definition
228
+ EXECUTE\s+(?:FUNCTION|PROCEDURE)\s+
229
+ (?:public\.)?(\w+)\s*\([^)]*\) # function name
230
+ )
231
+ """
232
+
233
+ for match in re.finditer(pattern, sql_content, re.DOTALL | re.IGNORECASE | re.VERBOSE):
234
+ full_def = match.group(1).strip()
235
+ trigger_name = match.group(2).lower()
236
+ table_name = match.group(3).lower()
237
+ triggers[trigger_name] = (full_def, table_name)
238
+
239
+ return triggers
240
+
241
+ async def get_db_triggers(self, conn: asyncpg.Connection) -> dict[str, tuple[str, str]]:
242
+ """
243
+ Get all trigger definitions from the database.
244
+
245
+ Returns:
246
+ Dict mapping trigger name -> (definition, table_name)
247
+ """
248
+ rows = await conn.fetch("""
249
+ SELECT
250
+ t.tgname as name,
251
+ pg_get_triggerdef(t.oid) as definition,
252
+ c.relname as table_name
253
+ FROM pg_trigger t
254
+ JOIN pg_class c ON t.tgrelid = c.oid
255
+ WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
256
+ AND NOT t.tgisinternal
257
+ """)
258
+
259
+ return {row["name"]: (row["definition"], row["table_name"]) for row in rows}
260
+
261
+ def _normalize_trigger_def(self, trigger_def: str) -> str:
262
+ """Normalize trigger definition for comparison."""
263
+ # Remove comments
264
+ trigger_def = re.sub(r"--.*$", "", trigger_def, flags=re.MULTILINE)
265
+
266
+ # Normalize whitespace
267
+ trigger_def = re.sub(r"\s+", " ", trigger_def).strip()
268
+
269
+ # Normalize case for keywords
270
+ keywords = [
271
+ "CREATE", "OR", "REPLACE", "TRIGGER", "BEFORE", "AFTER",
272
+ "INSTEAD", "OF", "INSERT", "UPDATE", "DELETE", "TRUNCATE",
273
+ "ON", "FOR", "EACH", "ROW", "STATEMENT", "EXECUTE",
274
+ "FUNCTION", "PROCEDURE", "WHEN", "NEW", "OLD", "AND", "OR",
275
+ ]
276
+ for kw in keywords:
277
+ trigger_def = re.sub(rf"\b{kw}\b", kw, trigger_def, flags=re.IGNORECASE)
278
+
279
+ return trigger_def
280
+
281
+ def _triggers_match(self, source_def: str, db_def: str) -> bool:
282
+ """Compare two trigger definitions for equivalence."""
283
+ norm_source = self._normalize_trigger_def(source_def)
284
+ norm_db = self._normalize_trigger_def(db_def)
285
+
286
+ # pg_get_triggerdef() output differs from source slightly
287
+ # Extract key components for comparison
288
+ def extract_key_parts(text: str) -> tuple:
289
+ # Extract: timing, events, table, for each, function
290
+ timing = re.search(r"\b(BEFORE|AFTER|INSTEAD OF)\b", text, re.IGNORECASE)
291
+ events = re.findall(r"\b(INSERT|UPDATE|DELETE|TRUNCATE)\b", text, re.IGNORECASE)
292
+ table = re.search(r"\bON\s+(?:public\.)?(\w+)", text, re.IGNORECASE)
293
+ for_each = re.search(r"\bFOR\s+EACH\s+(ROW|STATEMENT)\b", text, re.IGNORECASE)
294
+ func = re.search(r"\bEXECUTE\s+(?:FUNCTION|PROCEDURE)\s+(?:public\.)?(\w+)", text, re.IGNORECASE)
295
+
296
+ return (
297
+ timing.group(1).upper() if timing else "",
298
+ sorted(set(e.upper() for e in events)),
299
+ table.group(1).lower() if table else "",
300
+ for_each.group(1).upper() if for_each else "STATEMENT",
301
+ func.group(1).lower() if func else "",
302
+ )
303
+
304
+ return extract_key_parts(norm_source) == extract_key_parts(norm_db)
305
+
306
+ # =========================================================================
307
+ # VIEW EXTRACTION AND COMPARISON
308
+ # =========================================================================
309
+
310
+ def _extract_views_from_sql(self, sql_content: str) -> dict[str, str]:
311
+ """
312
+ Extract view definitions from SQL content.
313
+
314
+ Returns:
315
+ Dict mapping view name -> full CREATE VIEW statement
316
+ """
317
+ views = {}
318
+
319
+ # Pattern for CREATE [OR REPLACE] VIEW name AS SELECT ...
320
+ # Views end at semicolon
321
+ pattern = r"""
322
+ (CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+
323
+ (?:public\.)?(\w+)\s+ # view name
324
+ AS\s+
325
+ SELECT\s+.*?) # SELECT query
326
+ ; # terminated by semicolon
327
+ """
328
+
329
+ for match in re.finditer(pattern, sql_content, re.DOTALL | re.IGNORECASE | re.VERBOSE):
330
+ full_def = match.group(1).strip()
331
+ view_name = match.group(2).lower()
332
+ views[view_name] = full_def
333
+
334
+ return views
335
+
336
+ async def get_db_views(self, conn: asyncpg.Connection) -> dict[str, str]:
337
+ """Get all view definitions from the database."""
338
+ rows = await conn.fetch("""
339
+ SELECT
340
+ viewname as name,
341
+ 'CREATE OR REPLACE VIEW ' || viewname || ' AS ' || definition as definition
342
+ FROM pg_views
343
+ WHERE schemaname = 'public'
344
+ """)
345
+
346
+ return {row["name"]: row["definition"] for row in rows}
347
+
348
+ def _normalize_view_def(self, view_def: str) -> str:
349
+ """Normalize view definition for comparison."""
350
+ # Remove comments
351
+ view_def = re.sub(r"--.*$", "", view_def, flags=re.MULTILINE)
352
+
353
+ # Normalize whitespace
354
+ view_def = re.sub(r"\s+", " ", view_def).strip()
355
+
356
+ # Normalize case for keywords
357
+ keywords = [
358
+ "CREATE", "OR", "REPLACE", "VIEW", "AS", "SELECT", "FROM",
359
+ "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL",
360
+ "ON", "AND", "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE",
361
+ "IS", "NULL", "TRUE", "FALSE", "CASE", "WHEN", "THEN", "ELSE",
362
+ "END", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET",
363
+ "UNION", "INTERSECT", "EXCEPT", "ALL", "DISTINCT", "AS",
364
+ "COALESCE", "NULLIF", "CAST", "EXTRACT", "COUNT", "SUM",
365
+ "AVG", "MIN", "MAX", "WITH",
366
+ ]
367
+ for kw in keywords:
368
+ view_def = re.sub(rf"\b{kw}\b", kw, view_def, flags=re.IGNORECASE)
369
+
370
+ return view_def
371
+
372
+ def _views_match(self, source_def: str, db_def: str) -> bool:
373
+ """Compare two view definitions for equivalence."""
374
+ norm_source = self._normalize_view_def(source_def)
375
+ norm_db = self._normalize_view_def(db_def)
376
+
377
+ # Extract just the SELECT query for comparison
378
+ def extract_select(text: str) -> str:
379
+ match = re.search(r"\bAS\s+(SELECT\s+.*)", text, re.IGNORECASE | re.DOTALL)
380
+ if match:
381
+ return self._normalize_view_def(match.group(1))
382
+ return text
383
+
384
+ return extract_select(norm_source) == extract_select(norm_db)
385
+
386
+ # =========================================================================
387
+ # UNIFIED SOURCE EXTRACTION
388
+ # =========================================================================
389
+
390
+ def get_source_objects(self) -> tuple[dict[str, str], dict[str, tuple[str, str]], dict[str, str]]:
391
+ """
392
+ Extract all object definitions from SQL migration files.
393
+
394
+ Returns:
395
+ Tuple of (functions, triggers, views) dicts
396
+ """
397
+ all_functions = {}
398
+ all_triggers = {}
399
+ all_views = {}
400
+
401
+ if not self.sql_dir.exists():
402
+ logger.warning(f"SQL directory not found: {self.sql_dir}")
403
+ return all_functions, all_triggers, all_views
404
+
405
+ # Process migration files in order (later definitions override earlier)
406
+ for sql_file in sorted(self.sql_dir.glob("*.sql")):
407
+ content = sql_file.read_text()
408
+
409
+ functions = self._extract_functions_from_sql(content)
410
+ triggers = self._extract_triggers_from_sql(content)
411
+ views = self._extract_views_from_sql(content)
412
+
413
+ all_functions.update(functions)
414
+ all_triggers.update(triggers)
415
+ all_views.update(views)
416
+
417
+ if functions or triggers or views:
418
+ logger.debug(
419
+ f"{sql_file.name}: {len(functions)} functions, "
420
+ f"{len(triggers)} triggers, {len(views)} views"
421
+ )
422
+
423
+ return all_functions, all_triggers, all_views
424
+
425
+ # =========================================================================
426
+ # DIFF COMPUTATION
427
+ # =========================================================================
428
+
429
+ async def compute_diff(
430
+ self,
431
+ connection_string: Optional[str] = None,
432
+ include_extra: bool = False,
433
+ object_types: Optional[list[ObjectType]] = None,
434
+ ) -> DiffResult:
435
+ """
436
+ Compare objects between SQL files and database.
437
+
438
+ Args:
439
+ connection_string: PostgreSQL connection string (uses settings if not provided)
440
+ include_extra: If True, report objects in DB but not in source
441
+ object_types: Which object types to check (default: all)
442
+
443
+ Returns:
444
+ DiffResult with detected differences
445
+ """
446
+ if object_types is None:
447
+ object_types = list(ObjectType)
448
+
449
+ conn_str = connection_string or settings.postgres.connection_string
450
+
451
+ # Get source objects
452
+ source_functions, source_triggers, source_views = self.get_source_objects()
453
+ logger.info(
454
+ f"Source: {len(source_functions)} functions, "
455
+ f"{len(source_triggers)} triggers, {len(source_views)} views"
456
+ )
457
+
458
+ # Get database objects
459
+ conn = await asyncpg.connect(conn_str)
460
+ try:
461
+ db_functions = await self.get_db_functions(conn) if ObjectType.FUNCTION in object_types else {}
462
+ db_triggers = await self.get_db_triggers(conn) if ObjectType.TRIGGER in object_types else {}
463
+ db_views = await self.get_db_views(conn) if ObjectType.VIEW in object_types else {}
464
+ logger.info(
465
+ f"Database: {len(db_functions)} functions, "
466
+ f"{len(db_triggers)} triggers, {len(db_views)} views"
467
+ )
468
+ finally:
469
+ await conn.close()
470
+
471
+ diffs = []
472
+ sync_sql_parts = []
473
+
474
+ # Compare functions
475
+ if ObjectType.FUNCTION in object_types:
476
+ for name, source_def in source_functions.items():
477
+ if name not in db_functions:
478
+ diffs.append(ObjectDiff(
479
+ name=name,
480
+ object_type=ObjectType.FUNCTION,
481
+ status="missing",
482
+ source_def=source_def,
483
+ ))
484
+ sync_sql_parts.append(f"-- Missing function: {name}")
485
+ sync_sql_parts.append(source_def + ";")
486
+ sync_sql_parts.append("")
487
+ elif not self._functions_match(source_def, db_functions[name]):
488
+ diffs.append(ObjectDiff(
489
+ name=name,
490
+ object_type=ObjectType.FUNCTION,
491
+ status="different",
492
+ source_def=source_def,
493
+ db_def=db_functions[name],
494
+ ))
495
+ sync_sql_parts.append(f"-- Different function: {name}")
496
+ sync_sql_parts.append(source_def + ";")
497
+ sync_sql_parts.append("")
498
+
499
+ if include_extra:
500
+ for name in db_functions:
501
+ if name not in source_functions:
502
+ diffs.append(ObjectDiff(
503
+ name=name,
504
+ object_type=ObjectType.FUNCTION,
505
+ status="extra",
506
+ db_def=db_functions[name],
507
+ ))
508
+
509
+ # Compare triggers
510
+ if ObjectType.TRIGGER in object_types:
511
+ for name, (source_def, table_name) in source_triggers.items():
512
+ if name not in db_triggers:
513
+ diffs.append(ObjectDiff(
514
+ name=name,
515
+ object_type=ObjectType.TRIGGER,
516
+ status="missing",
517
+ source_def=source_def,
518
+ table_name=table_name,
519
+ ))
520
+ # Drop trigger first if exists (for CREATE without OR REPLACE)
521
+ sync_sql_parts.append(f"-- Missing trigger: {name}")
522
+ sync_sql_parts.append(f"DROP TRIGGER IF EXISTS {name} ON {table_name};")
523
+ sync_sql_parts.append(source_def + ";")
524
+ sync_sql_parts.append("")
525
+ elif not self._triggers_match(source_def, db_triggers[name][0]):
526
+ diffs.append(ObjectDiff(
527
+ name=name,
528
+ object_type=ObjectType.TRIGGER,
529
+ status="different",
530
+ source_def=source_def,
531
+ db_def=db_triggers[name][0],
532
+ table_name=table_name,
533
+ ))
534
+ sync_sql_parts.append(f"-- Different trigger: {name}")
535
+ sync_sql_parts.append(f"DROP TRIGGER IF EXISTS {name} ON {table_name};")
536
+ sync_sql_parts.append(source_def + ";")
537
+ sync_sql_parts.append("")
538
+
539
+ if include_extra:
540
+ for name in db_triggers:
541
+ if name not in source_triggers:
542
+ diffs.append(ObjectDiff(
543
+ name=name,
544
+ object_type=ObjectType.TRIGGER,
545
+ status="extra",
546
+ db_def=db_triggers[name][0],
547
+ table_name=db_triggers[name][1],
548
+ ))
549
+
550
+ # Compare views
551
+ if ObjectType.VIEW in object_types:
552
+ for name, source_def in source_views.items():
553
+ if name not in db_views:
554
+ diffs.append(ObjectDiff(
555
+ name=name,
556
+ object_type=ObjectType.VIEW,
557
+ status="missing",
558
+ source_def=source_def,
559
+ ))
560
+ sync_sql_parts.append(f"-- Missing view: {name}")
561
+ sync_sql_parts.append(source_def + ";")
562
+ sync_sql_parts.append("")
563
+ elif not self._views_match(source_def, db_views[name]):
564
+ diffs.append(ObjectDiff(
565
+ name=name,
566
+ object_type=ObjectType.VIEW,
567
+ status="different",
568
+ source_def=source_def,
569
+ db_def=db_views[name],
570
+ ))
571
+ sync_sql_parts.append(f"-- Different view: {name}")
572
+ sync_sql_parts.append(source_def + ";")
573
+ sync_sql_parts.append("")
574
+
575
+ if include_extra:
576
+ for name in db_views:
577
+ if name not in source_views:
578
+ diffs.append(ObjectDiff(
579
+ name=name,
580
+ object_type=ObjectType.VIEW,
581
+ status="extra",
582
+ db_def=db_views[name],
583
+ ))
584
+
585
+ has_changes = len([d for d in diffs if d.status in ("missing", "different")]) > 0
586
+
587
+ return DiffResult(
588
+ has_changes=has_changes,
589
+ diffs=diffs,
590
+ sync_sql="\n".join(sync_sql_parts) if sync_sql_parts else "",
591
+ )
592
+
593
+ async def sync(
594
+ self,
595
+ connection_string: Optional[str] = None,
596
+ dry_run: bool = True,
597
+ object_types: Optional[list[ObjectType]] = None,
598
+ ) -> DiffResult:
599
+ """
600
+ Sync objects from SQL files to database.
601
+
602
+ Args:
603
+ connection_string: PostgreSQL connection string
604
+ dry_run: If True, only report what would change (don't apply)
605
+ object_types: Which object types to sync (default: all)
606
+
607
+ Returns:
608
+ DiffResult with applied changes
609
+ """
610
+ result = await self.compute_diff(connection_string, object_types=object_types)
611
+
612
+ if not result.has_changes:
613
+ logger.info("All programmable objects are in sync")
614
+ return result
615
+
616
+ if dry_run:
617
+ logger.info(f"Dry run - changes needed:\n{result.summary()}")
618
+ return result
619
+
620
+ # Apply sync SQL
621
+ conn_str = connection_string or settings.postgres.connection_string
622
+ conn = await asyncpg.connect(conn_str)
623
+ try:
624
+ await conn.execute(result.sync_sql)
625
+ logger.info(f"Applied changes:\n{result.summary()}")
626
+ finally:
627
+ await conn.close()
628
+
629
+ return result
630
+
631
+
632
+ # Backwards compatibility alias
633
+ FunctionDiffService = ProgrammableDiffService
634
+ FunctionDiff = ObjectDiff
635
+ FunctionDiffResult = DiffResult
@@ -386,8 +386,8 @@ def _build_table(model: type[BaseModel], table_name: str, metadata: MetaData) ->
386
386
  )
387
387
  )
388
388
 
389
- # Tenant and user scoping
390
- columns.append(Column("tenant_id", String(100), nullable=False))
389
+ # Tenant and user scoping (tenant_id nullable - NULL means public/shared)
390
+ columns.append(Column("tenant_id", String(100), nullable=True))
391
391
  columns.append(Column("user_id", String(256), nullable=True))
392
392
 
393
393
  # Process Pydantic fields (skip system fields)