remdb 0.3.226__py3-none-any.whl → 0.3.245__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.

@@ -0,0 +1,109 @@
1
+ """
2
+ REM query command.
3
+
4
+ Usage:
5
+ rem query --sql 'LOOKUP "Sarah Chen"'
6
+ rem query --sql 'SEARCH resources "API design" LIMIT 10'
7
+ rem query --sql "SELECT * FROM resources LIMIT 5"
8
+ rem query --file queries/my_query.sql
9
+
10
+ This tool connects to the configured PostgreSQL instance and executes the
11
+ provided REM dialect query, printing results as JSON (default) or plain dicts.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ from pathlib import Path
19
+ from typing import List
20
+
21
+ import click
22
+ from loguru import logger
23
+
24
+ from ...services.rem import QueryExecutionError
25
+ from ...services.rem.service import RemService
26
+
27
+
28
+ @click.command("query")
29
+ @click.option("--sql", "-s", default=None, help="REM query string (LOOKUP, SEARCH, FUZZY, TRAVERSE, or SQL)")
30
+ @click.option(
31
+ "--file",
32
+ "-f",
33
+ "sql_file",
34
+ type=click.Path(exists=True, path_type=Path),
35
+ default=None,
36
+ help="Path to file containing REM query",
37
+ )
38
+ @click.option("--no-json", is_flag=True, default=False, help="Print rows as Python dicts instead of JSON")
39
+ @click.option("--user-id", "-u", default=None, help="Scope query to a specific user")
40
+ def query_command(sql: str | None, sql_file: Path | None, no_json: bool, user_id: str | None):
41
+ """
42
+ Execute a REM query against the database.
43
+
44
+ Supports REM dialect queries (LOOKUP, SEARCH, FUZZY, TRAVERSE) and raw SQL.
45
+ Either --sql or --file must be provided.
46
+ """
47
+ if not sql and not sql_file:
48
+ click.secho("Error: either --sql or --file is required", fg="red")
49
+ raise click.Abort()
50
+
51
+ # Read query from file if provided
52
+ if sql_file:
53
+ query_text = sql_file.read_text(encoding="utf-8")
54
+ else:
55
+ query_text = sql # type: ignore[assignment]
56
+
57
+ try:
58
+ asyncio.run(_run_query_async(query_text, not no_json, user_id))
59
+ except Exception as exc: # pragma: no cover - CLI error path
60
+ logger.exception("Query failed")
61
+ click.secho(f"✗ Query failed: {exc}", fg="red")
62
+ raise click.Abort()
63
+
64
+
65
+ async def _run_query_async(query_text: str, as_json: bool, user_id: str | None) -> None:
66
+ """
67
+ Execute the query using RemService.execute_query_string().
68
+ """
69
+ from ...services.postgres import get_postgres_service
70
+
71
+ db = get_postgres_service()
72
+ if not db:
73
+ click.secho("✗ PostgreSQL is disabled in settings. Enable with POSTGRES__ENABLED=true", fg="red")
74
+ raise click.Abort()
75
+
76
+ if db.pool is None:
77
+ await db.connect()
78
+
79
+ rem_service = RemService(db)
80
+
81
+ try:
82
+ # Use the unified execute_query_string method
83
+ result = await rem_service.execute_query_string(query_text, user_id=user_id)
84
+ output_rows = result.get("results", [])
85
+ except QueryExecutionError as qe:
86
+ logger.exception("Query execution failed")
87
+ click.secho(f"✗ Query execution failed: {qe}. Please check the query you provided and try again.", fg="red")
88
+ raise click.Abort()
89
+ except ValueError as ve:
90
+ # Parse errors from the query parser
91
+ click.secho(f"✗ Invalid query: {ve}", fg="red")
92
+ raise click.Abort()
93
+ except Exception as exc: # pragma: no cover - CLI error path
94
+ logger.exception("Unexpected error during query execution")
95
+ click.secho("✗ An unexpected error occurred while executing the query. Please check the query you provided and try again.", fg="red")
96
+ raise click.Abort()
97
+
98
+ if as_json:
99
+ click.echo(json.dumps(output_rows, default=str, indent=2))
100
+ else:
101
+ for r in output_rows:
102
+ click.echo(str(r))
103
+
104
+
105
+ def register_command(cli_group):
106
+ """Register the query command on the given CLI group (top-level)."""
107
+ cli_group.add_command(query_command)
108
+
109
+
@@ -331,6 +331,123 @@ async def _show_async(
331
331
  raise
332
332
 
333
333
 
334
+ @session.command("clone")
335
+ @click.argument("session_id")
336
+ @click.option("--to-turn", "-t", type=int, help="Clone up to turn N (counting user messages only)")
337
+ @click.option("--name", "-n", help="Name/description for the cloned session")
338
+ def clone(session_id: str, to_turn: int | None, name: str | None):
339
+ """
340
+ Clone a session for exploring alternate conversation paths.
341
+
342
+ SESSION_ID: The session ID to clone.
343
+
344
+ Examples:
345
+
346
+ # Clone entire session
347
+ rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0
348
+
349
+ # Clone up to turn 3 (first 3 user messages and their responses)
350
+ rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0 --to-turn 3
351
+
352
+ # Clone with a descriptive name
353
+ rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0 -n "Alternate anxiety path"
354
+ """
355
+ asyncio.run(_clone_async(session_id, to_turn, name))
356
+
357
+
358
+ async def _clone_async(
359
+ session_id: str,
360
+ to_turn: int | None,
361
+ name: str | None,
362
+ ):
363
+ """Async implementation of clone command."""
364
+ from uuid import uuid4
365
+ from ...models.entities.session import Session, SessionMode
366
+
367
+ pg = get_postgres_service()
368
+ if not pg:
369
+ logger.error("PostgreSQL not available")
370
+ return
371
+
372
+ await pg.connect()
373
+
374
+ try:
375
+ # Load original session messages
376
+ message_repo = Repository(Message, "messages", db=pg)
377
+ messages = await message_repo.find(
378
+ filters={"session_id": session_id},
379
+ order_by="created_at ASC",
380
+ limit=1000,
381
+ )
382
+
383
+ if not messages:
384
+ logger.error(f"No messages found for session {session_id}")
385
+ return
386
+
387
+ # If --to-turn specified, filter messages up to that turn (user messages)
388
+ if to_turn is not None:
389
+ user_count = 0
390
+ cutoff_idx = len(messages)
391
+ for idx, msg in enumerate(messages):
392
+ if msg.message_type == "user":
393
+ user_count += 1
394
+ if user_count > to_turn:
395
+ cutoff_idx = idx
396
+ break
397
+ messages = messages[:cutoff_idx]
398
+ logger.info(f"Cloning {len(messages)} messages (up to turn {to_turn})")
399
+ else:
400
+ logger.info(f"Cloning all {len(messages)} messages")
401
+
402
+ # Generate new session ID
403
+ new_session_id = str(uuid4())
404
+
405
+ # Get user_id and tenant_id from first message
406
+ first_msg = messages[0]
407
+ user_id = first_msg.user_id
408
+ tenant_id = first_msg.tenant_id or "default"
409
+
410
+ # Create Session record with CLONE mode and lineage
411
+ session_repo = Repository(Session, "sessions", db=pg)
412
+ new_session = Session(
413
+ id=uuid4(),
414
+ name=name or f"Clone of {session_id[:8]}",
415
+ mode=SessionMode.CLONE,
416
+ original_trace_id=session_id,
417
+ description=f"Cloned from session {session_id}" + (f" at turn {to_turn}" if to_turn else ""),
418
+ user_id=user_id,
419
+ tenant_id=tenant_id,
420
+ message_count=len(messages),
421
+ )
422
+ await session_repo.upsert(new_session)
423
+ logger.info(f"Created session record: {new_session.id}")
424
+
425
+ # Copy messages with new session_id
426
+ for msg in messages:
427
+ new_msg = Message(
428
+ id=uuid4(),
429
+ user_id=msg.user_id,
430
+ tenant_id=msg.tenant_id,
431
+ session_id=str(new_session.id),
432
+ content=msg.content,
433
+ message_type=msg.message_type,
434
+ metadata=msg.metadata,
435
+ )
436
+ await message_repo.upsert(new_msg)
437
+
438
+ click.echo(f"\n✅ Cloned session successfully!")
439
+ click.echo(f" Original: {session_id}")
440
+ click.echo(f" New: {new_session.id}")
441
+ click.echo(f" Messages: {len(messages)}")
442
+ if to_turn:
443
+ click.echo(f" Turns: {to_turn}")
444
+ click.echo(f"\nContinue this session with:")
445
+ click.echo(f" rem ask <agent> \"your message\" --session-id {new_session.id}")
446
+
447
+ finally:
448
+ await pg.disconnect()
449
+
450
+
334
451
  def register_command(cli_group):
335
452
  """Register the session command group."""
336
453
  cli_group.add_command(session)
rem/cli/main.py CHANGED
@@ -97,6 +97,7 @@ from .commands.mcp import register_command as register_mcp_command
97
97
  from .commands.scaffold import scaffold as scaffold_command
98
98
  from .commands.cluster import register_commands as register_cluster_commands
99
99
  from .commands.session import register_command as register_session_command
100
+ from .commands.query import register_command as register_query_command
100
101
 
101
102
  register_schema_commands(schema)
102
103
  register_db_commands(db)
@@ -107,6 +108,7 @@ register_ask_command(cli)
107
108
  register_configure_command(cli)
108
109
  register_serve_command(cli)
109
110
  register_mcp_command(cli)
111
+ register_query_command(cli)
110
112
  cli.add_command(experiments_group)
111
113
  cli.add_command(scaffold_command)
112
114
  register_session_command(cli)
@@ -21,6 +21,7 @@ class SessionMode(str, Enum):
21
21
 
22
22
  NORMAL = "normal"
23
23
  EVALUATION = "evaluation"
24
+ CLONE = "clone"
24
25
 
25
26
 
26
27
  class Session(CoreModel):
@@ -31,27 +31,17 @@ if TYPE_CHECKING:
31
31
  from .service import PostgresService
32
32
 
33
33
 
34
- # Singleton instance for connection pool reuse
35
- _postgres_instance: "PostgresService | None" = None
36
-
37
-
38
34
  def get_postgres_service() -> "PostgresService | None":
39
35
  """
40
- Get PostgresService singleton instance.
36
+ Get PostgresService singleton from parent module.
41
37
 
42
- Returns None if Postgres is disabled.
43
- Uses singleton pattern to prevent connection pool exhaustion.
38
+ Uses late import to avoid circular import issues.
39
+ Previously had a separate _postgres_instance here which caused
40
+ "pool not connected" errors due to duplicate connection pools.
44
41
  """
45
- global _postgres_instance
46
-
47
- if not settings.postgres.enabled:
48
- return None
49
-
50
- if _postgres_instance is None:
51
- from .service import PostgresService
52
- _postgres_instance = PostgresService()
53
-
54
- return _postgres_instance
42
+ # Late import to avoid circular import (repository.py imported by __init__.py)
43
+ from rem.services.postgres import get_postgres_service as _get_singleton
44
+ return _get_singleton()
55
45
 
56
46
  T = TypeVar("T", bound=BaseModel)
57
47
 
@@ -478,6 +478,53 @@ class RemService:
478
478
  parser = RemQueryParser()
479
479
  return parser.parse(query_string)
480
480
 
481
+ async def execute_query_string(
482
+ self, query_string: str, user_id: str | None = None
483
+ ) -> dict[str, Any]:
484
+ """
485
+ Execute a REM dialect query string directly.
486
+
487
+ This is the unified entry point for executing REM queries from both
488
+ the CLI and API. It handles parsing the query string, creating the
489
+ RemQuery model, and executing it.
490
+
491
+ Args:
492
+ query_string: REM dialect query (e.g., 'LOOKUP "Sarah Chen"',
493
+ 'SEARCH resources "API design"', 'SELECT * FROM users')
494
+ user_id: Optional user ID for query isolation
495
+
496
+ Returns:
497
+ Dict with query results and metadata:
498
+ - query_type: The type of query executed
499
+ - results: List of result rows
500
+ - count: Number of results
501
+ - Additional fields depending on query type
502
+
503
+ Raises:
504
+ ValueError: If the query string is invalid
505
+ QueryExecutionError: If query execution fails
506
+
507
+ Example:
508
+ >>> result = await rem_service.execute_query_string(
509
+ ... 'LOOKUP "Sarah Chen"',
510
+ ... user_id="user-123"
511
+ ... )
512
+ >>> print(result["count"])
513
+ 1
514
+ """
515
+ # Parse the query string into type and parameters
516
+ query_type, parameters = self._parse_query_string(query_string)
517
+
518
+ # Create and validate the RemQuery model
519
+ rem_query = RemQuery.model_validate({
520
+ "query_type": query_type,
521
+ "parameters": parameters,
522
+ "user_id": user_id,
523
+ })
524
+
525
+ # Execute and return results
526
+ return await self.execute_query(rem_query)
527
+
481
528
  async def ask_rem(
482
529
  self, natural_query: str, tenant_id: str, llm_model: str | None = None, plan_mode: bool = False
483
530
  ) -> dict[str, Any]:
@@ -96,7 +96,7 @@ class MessageCompressor:
96
96
  Returns:
97
97
  Compressed message dict
98
98
  """
99
- content = message.get("content", "")
99
+ content = message.get("content") or ""
100
100
 
101
101
  # Don't compress short messages or system messages
102
102
  if (
@@ -242,7 +242,7 @@ class SessionMessageStore:
242
242
  # Use pre-generated id from message dict if available (for frontend feedback)
243
243
  msg = Message(
244
244
  id=message.get("id"), # Use pre-generated ID if provided
245
- content=message.get("content", ""),
245
+ content=message.get("content") or "",
246
246
  message_type=message.get("role", "assistant"),
247
247
  session_id=session_id,
248
248
  tenant_id=self.user_id, # Set tenant_id to user_id (application scoped to user)
@@ -337,7 +337,7 @@ class SessionMessageStore:
337
337
  compressed_messages = []
338
338
 
339
339
  for idx, message in enumerate(messages):
340
- content = message.get("content", "")
340
+ content = message.get("content") or ""
341
341
 
342
342
  # Only store and compress long assistant responses
343
343
  if (
@@ -368,6 +368,8 @@ class SessionMessageStore:
368
368
  }
369
369
 
370
370
  # For tool messages, include tool call details in metadata
371
+ # Note: tool_arguments is stored only when provided (parent tool calls)
372
+ # For child tool calls (e.g., register_metadata), args are in content as JSON
371
373
  if message.get("role") == "tool":
372
374
  if message.get("tool_call_id"):
373
375
  msg_metadata["tool_call_id"] = message.get("tool_call_id")
@@ -436,6 +438,8 @@ class SessionMessageStore:
436
438
  }
437
439
 
438
440
  # For tool messages, reconstruct tool call metadata
441
+ # Note: tool_arguments may be in metadata (parent calls) or parsed from
442
+ # content (child calls like register_metadata) by pydantic_messages.py
439
443
  if role == "tool" and msg.metadata:
440
444
  if msg.metadata.get("tool_call_id"):
441
445
  msg_dict["tool_call_id"] = msg.metadata["tool_call_id"]
@@ -5,12 +5,16 @@ storage format into pydantic-ai's native ModelRequest/ModelResponse types.
5
5
 
6
6
  Key insight: When we store tool results, we only store the result (ToolReturnPart).
7
7
  But LLM APIs require matching ToolCallPart for each ToolReturnPart. So we synthesize
8
- the ToolCallPart from stored metadata (tool_name, tool_call_id, tool_arguments).
8
+ the ToolCallPart from stored metadata (tool_name, tool_call_id) and arguments.
9
+
10
+ Tool arguments can come from two places:
11
+ - Parent tool calls (ask_agent): tool_arguments stored in metadata (content = result)
12
+ - Child tool calls (register_metadata): arguments parsed from content (content = args as JSON)
9
13
 
10
14
  Storage format (our simplified format):
11
15
  {"role": "user", "content": "..."}
12
16
  {"role": "assistant", "content": "..."}
13
- {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}}
17
+ {"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}} # optional
14
18
 
15
19
  Pydantic-ai format (what the LLM expects):
16
20
  ModelRequest(parts=[UserPromptPart(content="...")])
@@ -31,6 +35,7 @@ Example usage:
31
35
  """
32
36
 
33
37
  import json
38
+ import re
34
39
  from typing import Any
35
40
 
36
41
  from loguru import logger
@@ -46,6 +51,15 @@ from pydantic_ai.messages import (
46
51
  )
47
52
 
48
53
 
54
+ def _sanitize_tool_name(tool_name: str) -> str:
55
+ """Sanitize tool name for OpenAI API compatibility.
56
+
57
+ OpenAI requires tool names to match pattern: ^[a-zA-Z0-9_-]+$
58
+ This replaces invalid characters (like colons) with underscores.
59
+ """
60
+ return re.sub(r'[^a-zA-Z0-9_-]', '_', tool_name)
61
+
62
+
49
63
  def session_to_pydantic_messages(
50
64
  session_history: list[dict[str, Any]],
51
65
  system_prompt: str | None = None,
@@ -92,7 +106,7 @@ def session_to_pydantic_messages(
92
106
  while i < len(session_history):
93
107
  msg = session_history[i]
94
108
  role = msg.get("role", "")
95
- content = msg.get("content", "")
109
+ content = msg.get("content") or ""
96
110
 
97
111
  if role == "user":
98
112
  # User messages become ModelRequest with UserPromptPart
@@ -110,8 +124,15 @@ def session_to_pydantic_messages(
110
124
  tool_msg = session_history[j]
111
125
  tool_name = tool_msg.get("tool_name", "unknown_tool")
112
126
  tool_call_id = tool_msg.get("tool_call_id", f"call_{j}")
113
- tool_arguments = tool_msg.get("tool_arguments", {})
114
- tool_content = tool_msg.get("content", "{}")
127
+ tool_content = tool_msg.get("content") or "{}"
128
+
129
+ # tool_arguments: prefer explicit field, fallback to parsing content
130
+ tool_arguments = tool_msg.get("tool_arguments")
131
+ if tool_arguments is None and isinstance(tool_content, str) and tool_content:
132
+ try:
133
+ tool_arguments = json.loads(tool_content)
134
+ except json.JSONDecodeError:
135
+ tool_arguments = {}
115
136
 
116
137
  # Parse tool content if it's a JSON string
117
138
  if isinstance(tool_content, str):
@@ -122,16 +143,19 @@ def session_to_pydantic_messages(
122
143
  else:
123
144
  tool_result = tool_content
124
145
 
146
+ # Sanitize tool name for OpenAI API compatibility
147
+ safe_tool_name = _sanitize_tool_name(tool_name)
148
+
125
149
  # Synthesize ToolCallPart (what the model "called")
126
150
  tool_calls.append(ToolCallPart(
127
- tool_name=tool_name,
151
+ tool_name=safe_tool_name,
128
152
  args=tool_arguments if tool_arguments else {},
129
153
  tool_call_id=tool_call_id,
130
154
  ))
131
155
 
132
156
  # Create ToolReturnPart (the actual result)
133
157
  tool_returns.append(ToolReturnPart(
134
- tool_name=tool_name,
158
+ tool_name=safe_tool_name,
135
159
  content=tool_result,
136
160
  tool_call_id=tool_call_id,
137
161
  ))
@@ -166,8 +190,15 @@ def session_to_pydantic_messages(
166
190
  # Orphan tool message (no preceding assistant) - synthesize both parts
167
191
  tool_name = msg.get("tool_name", "unknown_tool")
168
192
  tool_call_id = msg.get("tool_call_id", f"call_{i}")
169
- tool_arguments = msg.get("tool_arguments", {})
170
- tool_content = msg.get("content", "{}")
193
+ tool_content = msg.get("content") or "{}"
194
+
195
+ # tool_arguments: prefer explicit field, fallback to parsing content
196
+ tool_arguments = msg.get("tool_arguments")
197
+ if tool_arguments is None and isinstance(tool_content, str) and tool_content:
198
+ try:
199
+ tool_arguments = json.loads(tool_content)
200
+ except json.JSONDecodeError:
201
+ tool_arguments = {}
171
202
 
172
203
  # Parse tool content
173
204
  if isinstance(tool_content, str):
@@ -178,10 +209,13 @@ def session_to_pydantic_messages(
178
209
  else:
179
210
  tool_result = tool_content
180
211
 
212
+ # Sanitize tool name for OpenAI API compatibility
213
+ safe_tool_name = _sanitize_tool_name(tool_name)
214
+
181
215
  # Synthesize the tool call (ModelResponse with ToolCallPart)
182
216
  messages.append(ModelResponse(
183
217
  parts=[ToolCallPart(
184
- tool_name=tool_name,
218
+ tool_name=safe_tool_name,
185
219
  args=tool_arguments if tool_arguments else {},
186
220
  tool_call_id=tool_call_id,
187
221
  )],
@@ -191,7 +225,7 @@ def session_to_pydantic_messages(
191
225
  # Add the tool return (ModelRequest with ToolReturnPart)
192
226
  messages.append(ModelRequest(
193
227
  parts=[ToolReturnPart(
194
- tool_name=tool_name,
228
+ tool_name=safe_tool_name,
195
229
  content=tool_result,
196
230
  tool_call_id=tool_call_id,
197
231
  )]
@@ -12,7 +12,8 @@ Design Pattern:
12
12
 
13
13
  Message Types on Reload:
14
14
  - user: Returned as-is
15
- - tool: Returned as-is with metadata (tool_call_id, tool_name, tool_arguments)
15
+ - tool: Returned with metadata (tool_call_id, tool_name). tool_arguments may be in
16
+ metadata (parent calls) or parsed from content (child calls) by pydantic_messages.py
16
17
  - assistant: Compressed on load if long (>400 chars), with REM LOOKUP for recovery
17
18
  """
18
19
 
rem/settings.py CHANGED
@@ -424,6 +424,49 @@ class AuthSettings(BaseSettings):
424
424
  google: GoogleOAuthSettings = Field(default_factory=GoogleOAuthSettings)
425
425
  microsoft: MicrosoftOAuthSettings = Field(default_factory=MicrosoftOAuthSettings)
426
426
 
427
+ # Pre-approved login codes (bypass email verification)
428
+ # Format: comma-separated codes with prefix A=admin, B=normal user
429
+ # Example: "A12345,A67890,B11111,B22222"
430
+ preapproved_codes: str = Field(
431
+ default="",
432
+ description=(
433
+ "Comma-separated list of pre-approved login codes. "
434
+ "Prefix A = admin user, B = normal user. "
435
+ "Example: 'A12345,A67890,B11111'. "
436
+ "Users can login with these codes without email verification."
437
+ ),
438
+ )
439
+
440
+ def check_preapproved_code(self, code: str) -> dict | None:
441
+ """
442
+ Check if a code is in the pre-approved list.
443
+
444
+ Args:
445
+ code: The code to check (including prefix)
446
+
447
+ Returns:
448
+ Dict with 'role' key if valid, None if not found.
449
+ - A prefix -> role='admin'
450
+ - B prefix -> role='user'
451
+ """
452
+ if not self.preapproved_codes:
453
+ return None
454
+
455
+ codes = [c.strip().upper() for c in self.preapproved_codes.split(",") if c.strip()]
456
+ code_upper = code.strip().upper()
457
+
458
+ if code_upper not in codes:
459
+ return None
460
+
461
+ # Parse prefix to determine role
462
+ if code_upper.startswith("A"):
463
+ return {"role": "admin", "code": code_upper}
464
+ elif code_upper.startswith("B"):
465
+ return {"role": "user", "code": code_upper}
466
+ else:
467
+ # Unknown prefix, treat as user
468
+ return {"role": "user", "code": code_upper}
469
+
427
470
  @field_validator("session_secret", mode="before")
428
471
  @classmethod
429
472
  def generate_dev_secret(cls, v: str | None, info: ValidationInfo) -> str:
@@ -64,9 +64,11 @@ CREATE OR REPLACE FUNCTION rem_kv_store_empty(p_user_id TEXT)
64
64
  RETURNS BOOLEAN AS $$
65
65
  BEGIN
66
66
  -- Quick existence check - very fast with index
67
+ -- Check for user-specific OR public (NULL user_id) entries
68
+ -- This ensures self-healing triggers correctly for public ontologies
67
69
  RETURN NOT EXISTS (
68
70
  SELECT 1 FROM kv_store
69
- WHERE user_id = p_user_id
71
+ WHERE user_id = p_user_id OR user_id IS NULL
70
72
  LIMIT 1
71
73
  );
72
74
  END;