htmlgraph 0.9.3__py3-none-any.whl → 0.27.5__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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +173 -17
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +127 -0
- htmlgraph/agent_registry.py +45 -30
- htmlgraph/agents.py +160 -107
- htmlgraph/analytics/__init__.py +9 -2
- htmlgraph/analytics/cli.py +190 -51
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +192 -100
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +190 -14
- htmlgraph/analytics_index.py +135 -51
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +208 -0
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/__init__.py +14 -0
- htmlgraph/builders/base.py +118 -29
- htmlgraph/builders/bug.py +150 -0
- htmlgraph/builders/chore.py +119 -0
- htmlgraph/builders/epic.py +150 -0
- htmlgraph/builders/feature.py +31 -6
- htmlgraph/builders/insight.py +195 -0
- htmlgraph/builders/metric.py +217 -0
- htmlgraph/builders/pattern.py +202 -0
- htmlgraph/builders/phase.py +162 -0
- htmlgraph/builders/spike.py +52 -19
- htmlgraph/builders/track.py +148 -72
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +18 -0
- htmlgraph/collections/base.py +415 -98
- htmlgraph/collections/bug.py +53 -0
- htmlgraph/collections/chore.py +53 -0
- htmlgraph/collections/epic.py +53 -0
- htmlgraph/collections/feature.py +12 -26
- htmlgraph/collections/insight.py +100 -0
- htmlgraph/collections/metric.py +92 -0
- htmlgraph/collections/pattern.py +97 -0
- htmlgraph/collections/phase.py +53 -0
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +56 -16
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +511 -0
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +344 -0
- htmlgraph/converter.py +216 -28
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2406 -307
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +19 -2
- htmlgraph/deploy.py +142 -125
- htmlgraph/deployment_models.py +474 -0
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +182 -27
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +100 -52
- htmlgraph/event_migration.py +13 -4
- htmlgraph/exceptions.py +49 -0
- htmlgraph/file_watcher.py +101 -28
- htmlgraph/find_api.py +75 -63
- htmlgraph/git_events.py +145 -63
- htmlgraph/graph.py +1122 -106
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +45 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +1314 -0
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/hooks-config.example.json +12 -0
- htmlgraph/hooks/installer.py +343 -0
- htmlgraph/hooks/orchestrator.py +674 -0
- htmlgraph/hooks/orchestrator_reflector.py +223 -0
- htmlgraph/hooks/post-checkout.sh +28 -0
- htmlgraph/hooks/post-commit.sh +24 -0
- htmlgraph/hooks/post-merge.sh +26 -0
- htmlgraph/hooks/post_tool_use_failure.py +273 -0
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +408 -0
- htmlgraph/hooks/pre-commit.sh +94 -0
- htmlgraph/hooks/pre-push.sh +28 -0
- htmlgraph/hooks/pretooluse.py +819 -0
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +255 -0
- htmlgraph/hooks/task_validator.py +177 -0
- htmlgraph/hooks/validator.py +628 -0
- htmlgraph/ids.py +41 -27
- htmlgraph/index.d.ts +286 -0
- htmlgraph/learning.py +767 -0
- htmlgraph/mcp_server.py +69 -23
- htmlgraph/models.py +1586 -87
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/orchestration/task_coordination.py +343 -0
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +669 -0
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +328 -0
- htmlgraph/orchestrator_validator.py +133 -0
- htmlgraph/parallel.py +646 -0
- htmlgraph/parser.py +160 -35
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/planning.py +147 -52
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +109 -72
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/routing.py +8 -19
- htmlgraph/scripts/deploy.py +1 -2
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +685 -180
- htmlgraph/services/__init__.py +10 -0
- htmlgraph/services/claiming.py +199 -0
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +1392 -175
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +201 -0
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/setup.py +34 -17
- htmlgraph/spike_index.py +143 -0
- htmlgraph/sync_docs.py +12 -15
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph/templates/GEMINI.md.template +87 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +146 -15
- htmlgraph/track_manager.py +69 -21
- htmlgraph/transcript.py +890 -0
- htmlgraph/transcript_analytics.py +699 -0
- htmlgraph/types.py +323 -0
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +8 -5
- htmlgraph/work_type_utils.py +3 -2
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2406 -307
- htmlgraph-0.27.5.data/data/htmlgraph/templates/AGENTS.md.template +366 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/CLAUDE.md.template +97 -0
- htmlgraph-0.27.5.data/data/htmlgraph/templates/GEMINI.md.template +87 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +97 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -2688
- htmlgraph/sdk.py +0 -709
- htmlgraph-0.9.3.dist-info/RECORD +0 -61
- {htmlgraph-0.9.3.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.9.3.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLiteFeatureRepository - SQLite database-based Feature storage.
|
|
3
|
+
|
|
4
|
+
Stores features in SQLite database for fast queries and unified storage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import builtins
|
|
8
|
+
import json
|
|
9
|
+
import sqlite3
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from htmlgraph.db.schema import HtmlGraphDB
|
|
16
|
+
from htmlgraph.models import Edge, Node, Step
|
|
17
|
+
from htmlgraph.repositories.feature_repository import (
|
|
18
|
+
FeatureNotFoundError,
|
|
19
|
+
FeatureRepository,
|
|
20
|
+
FeatureValidationError,
|
|
21
|
+
RepositoryQuery,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SQLiteRepositoryQuery(RepositoryQuery):
|
|
26
|
+
"""Query builder for SQLite filtering."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, repo: "SQLiteFeatureRepository", filters: dict[str, Any]):
|
|
29
|
+
super().__init__(filters)
|
|
30
|
+
self._repo = repo
|
|
31
|
+
|
|
32
|
+
def where(self, **kwargs: Any) -> "SQLiteRepositoryQuery":
|
|
33
|
+
"""Chain additional filters."""
|
|
34
|
+
# Validate filter keys
|
|
35
|
+
valid_attrs = {
|
|
36
|
+
"status",
|
|
37
|
+
"priority",
|
|
38
|
+
"track_id",
|
|
39
|
+
"assigned_to",
|
|
40
|
+
"type",
|
|
41
|
+
"title",
|
|
42
|
+
"id",
|
|
43
|
+
"created",
|
|
44
|
+
"updated",
|
|
45
|
+
}
|
|
46
|
+
for key in kwargs:
|
|
47
|
+
if key not in valid_attrs:
|
|
48
|
+
raise FeatureValidationError(f"Invalid filter attribute: {key}")
|
|
49
|
+
|
|
50
|
+
# Merge filters
|
|
51
|
+
new_filters = {**self.filters, **kwargs}
|
|
52
|
+
return SQLiteRepositoryQuery(self._repo, new_filters)
|
|
53
|
+
|
|
54
|
+
def execute(self) -> list[Any]:
|
|
55
|
+
"""Execute the query and return results."""
|
|
56
|
+
return self._repo.list(self.filters)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SQLiteFeatureRepository(FeatureRepository):
|
|
60
|
+
"""
|
|
61
|
+
SQLite database-based FeatureRepository implementation.
|
|
62
|
+
|
|
63
|
+
Stores features in a SQLite database for fast queries and transactions.
|
|
64
|
+
Uses parameterized queries to prevent SQL injection.
|
|
65
|
+
|
|
66
|
+
Database schema:
|
|
67
|
+
Table: features
|
|
68
|
+
Columns: id, type, title, description, status, priority,
|
|
69
|
+
assigned_to, track_id, created_at, updated_at, completed_at,
|
|
70
|
+
steps_total, steps_completed, metadata (JSON)
|
|
71
|
+
|
|
72
|
+
Performance:
|
|
73
|
+
- get(id): O(1) with cache, O(log n) from database (indexed)
|
|
74
|
+
- list(): O(n) with SQL WHERE clauses
|
|
75
|
+
- batch operations: O(k) vectorized SQL
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
>>> db_path = Path(".htmlgraph/htmlgraph.db")
|
|
79
|
+
>>> repo = SQLiteFeatureRepository(db_path)
|
|
80
|
+
>>> feature = repo.create("User Authentication", priority="high")
|
|
81
|
+
>>> feature.status = "in-progress"
|
|
82
|
+
>>> repo.save(feature)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, db_path: Path | str, auto_load: bool = True):
|
|
86
|
+
"""
|
|
87
|
+
Initialize SQLite repository.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
db_path: Path to SQLite database file
|
|
91
|
+
auto_load: Whether to enable auto-loading (always True for DB)
|
|
92
|
+
"""
|
|
93
|
+
self._db_path = Path(db_path)
|
|
94
|
+
self._auto_load = auto_load
|
|
95
|
+
|
|
96
|
+
# Identity cache: feature_id -> Node instance
|
|
97
|
+
self._cache: dict[str, Node] = {}
|
|
98
|
+
|
|
99
|
+
# Initialize database connection
|
|
100
|
+
self._db = HtmlGraphDB(str(self._db_path))
|
|
101
|
+
self._db.connect()
|
|
102
|
+
self._db.create_tables()
|
|
103
|
+
|
|
104
|
+
# Disable foreign key constraints for testing
|
|
105
|
+
# (allows inserting features with track_ids that don't exist)
|
|
106
|
+
if self._db.connection:
|
|
107
|
+
self._db.connection.execute("PRAGMA foreign_keys = OFF")
|
|
108
|
+
|
|
109
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
110
|
+
"""Get database connection."""
|
|
111
|
+
if not self._db.connection:
|
|
112
|
+
self._db.connect()
|
|
113
|
+
assert self._db.connection is not None
|
|
114
|
+
return self._db.connection
|
|
115
|
+
|
|
116
|
+
def _row_to_node(self, row: sqlite3.Row) -> Node:
|
|
117
|
+
"""Convert database row to Node object."""
|
|
118
|
+
# Parse JSON fields
|
|
119
|
+
metadata = json.loads(row["metadata"]) if row["metadata"] else {}
|
|
120
|
+
json.loads(row["tags"]) if row["tags"] else []
|
|
121
|
+
|
|
122
|
+
# Extract steps from metadata
|
|
123
|
+
steps_data = metadata.get("steps", [])
|
|
124
|
+
steps = [
|
|
125
|
+
Step(
|
|
126
|
+
description=s.get("description", ""),
|
|
127
|
+
completed=s.get("completed", False),
|
|
128
|
+
agent=s.get("agent"),
|
|
129
|
+
)
|
|
130
|
+
for s in steps_data
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Extract edges from metadata
|
|
134
|
+
edges_data = metadata.get("edges", {})
|
|
135
|
+
edges = {}
|
|
136
|
+
for rel_type, edge_list in edges_data.items():
|
|
137
|
+
edges[rel_type] = [
|
|
138
|
+
Edge(
|
|
139
|
+
target_id=e.get("target_id", ""),
|
|
140
|
+
relationship=e.get("relationship", rel_type),
|
|
141
|
+
title=e.get("title"),
|
|
142
|
+
since=e.get("since"),
|
|
143
|
+
properties=e.get("properties", {}),
|
|
144
|
+
)
|
|
145
|
+
for e in edge_list
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
# Create Node
|
|
149
|
+
node = Node(
|
|
150
|
+
id=row["id"],
|
|
151
|
+
title=row["title"],
|
|
152
|
+
type=row["type"],
|
|
153
|
+
status=row["status"],
|
|
154
|
+
priority=row["priority"],
|
|
155
|
+
created=datetime.fromisoformat(row["created_at"])
|
|
156
|
+
if row["created_at"]
|
|
157
|
+
else datetime.now(),
|
|
158
|
+
updated=datetime.fromisoformat(row["updated_at"])
|
|
159
|
+
if row["updated_at"]
|
|
160
|
+
else datetime.now(),
|
|
161
|
+
content=row["description"] or "",
|
|
162
|
+
agent_assigned=row["assigned_to"],
|
|
163
|
+
track_id=row["track_id"],
|
|
164
|
+
steps=steps,
|
|
165
|
+
edges=edges,
|
|
166
|
+
properties=metadata.get("properties", {}),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return node
|
|
170
|
+
|
|
171
|
+
def _node_to_row(self, node: Node) -> dict[str, Any]:
|
|
172
|
+
"""Convert Node object to database row dict."""
|
|
173
|
+
# Serialize steps
|
|
174
|
+
steps_data = [
|
|
175
|
+
{"description": s.description, "completed": s.completed, "agent": s.agent}
|
|
176
|
+
for s in (node.steps or [])
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
# Serialize edges (handle both Edge objects and dicts)
|
|
180
|
+
edges_data = {}
|
|
181
|
+
for rel_type, edge_list in (node.edges or {}).items():
|
|
182
|
+
serialized_edges = []
|
|
183
|
+
for e in edge_list:
|
|
184
|
+
if isinstance(e, dict):
|
|
185
|
+
# Already a dict, use as-is with defaults
|
|
186
|
+
serialized_edges.append(
|
|
187
|
+
{
|
|
188
|
+
"target_id": e.get("target_id"),
|
|
189
|
+
"relationship": e.get("relationship", rel_type),
|
|
190
|
+
"title": e.get("title", ""),
|
|
191
|
+
"since": e.get("since"),
|
|
192
|
+
"properties": e.get("properties", {}),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
# Edge object
|
|
197
|
+
serialized_edges.append(
|
|
198
|
+
{
|
|
199
|
+
"target_id": e.target_id,
|
|
200
|
+
"relationship": e.relationship,
|
|
201
|
+
"title": e.title,
|
|
202
|
+
"since": e.since.isoformat() if e.since else None,
|
|
203
|
+
"properties": e.properties,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
edges_data[rel_type] = serialized_edges
|
|
207
|
+
|
|
208
|
+
# Build metadata
|
|
209
|
+
metadata = {
|
|
210
|
+
"steps": steps_data,
|
|
211
|
+
"edges": edges_data,
|
|
212
|
+
"properties": node.properties or {},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"id": node.id,
|
|
217
|
+
"type": node.type,
|
|
218
|
+
"title": node.title,
|
|
219
|
+
"description": node.content,
|
|
220
|
+
"status": node.status,
|
|
221
|
+
"priority": node.priority,
|
|
222
|
+
"assigned_to": node.agent_assigned,
|
|
223
|
+
"track_id": node.track_id,
|
|
224
|
+
"created_at": node.created.isoformat()
|
|
225
|
+
if node.created
|
|
226
|
+
else datetime.now().isoformat(),
|
|
227
|
+
"updated_at": node.updated.isoformat()
|
|
228
|
+
if node.updated
|
|
229
|
+
else datetime.now().isoformat(),
|
|
230
|
+
"completed_at": None, # TODO: extract from metadata
|
|
231
|
+
"steps_total": len(node.steps) if node.steps else 0,
|
|
232
|
+
"steps_completed": sum(1 for s in (node.steps or []) if s.completed),
|
|
233
|
+
"tags": json.dumps([]),
|
|
234
|
+
"metadata": json.dumps(metadata),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def _generate_id(self) -> str:
|
|
238
|
+
"""Generate unique feature ID."""
|
|
239
|
+
import uuid
|
|
240
|
+
|
|
241
|
+
return f"feat-{uuid.uuid4().hex[:8]}"
|
|
242
|
+
|
|
243
|
+
def _validate_feature(self, feature: Any) -> None:
|
|
244
|
+
"""Validate feature object."""
|
|
245
|
+
if not hasattr(feature, "id"):
|
|
246
|
+
raise FeatureValidationError("Feature must have 'id' attribute")
|
|
247
|
+
if not hasattr(feature, "title"):
|
|
248
|
+
raise FeatureValidationError("Feature must have 'title' attribute")
|
|
249
|
+
if not feature.id or not str(feature.id).strip():
|
|
250
|
+
raise FeatureValidationError("Feature ID cannot be empty")
|
|
251
|
+
if not feature.title or not str(feature.title).strip():
|
|
252
|
+
raise FeatureValidationError("Feature title cannot be empty")
|
|
253
|
+
|
|
254
|
+
def _build_where_clause(self, filters: dict[str, Any]) -> tuple[str, list]:
|
|
255
|
+
"""Build SQL WHERE clause from filters."""
|
|
256
|
+
if not filters:
|
|
257
|
+
return "", []
|
|
258
|
+
|
|
259
|
+
conditions = []
|
|
260
|
+
params = []
|
|
261
|
+
|
|
262
|
+
# Map filter keys to database columns
|
|
263
|
+
column_map = {
|
|
264
|
+
"status": "status",
|
|
265
|
+
"priority": "priority",
|
|
266
|
+
"track_id": "track_id",
|
|
267
|
+
"agent_assigned": "assigned_to",
|
|
268
|
+
"type": "type",
|
|
269
|
+
"title": "title",
|
|
270
|
+
"id": "id",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for key, value in filters.items():
|
|
274
|
+
if key in column_map:
|
|
275
|
+
conditions.append(f"{column_map[key]} = ?")
|
|
276
|
+
params.append(value)
|
|
277
|
+
|
|
278
|
+
where_clause = " AND ".join(conditions)
|
|
279
|
+
return f"WHERE {where_clause}" if where_clause else "", params
|
|
280
|
+
|
|
281
|
+
# ===== READ OPERATIONS =====
|
|
282
|
+
|
|
283
|
+
def get(self, feature_id: str) -> Node | None:
|
|
284
|
+
"""
|
|
285
|
+
Get single feature by ID.
|
|
286
|
+
|
|
287
|
+
Returns same object instance for multiple calls (identity caching).
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
feature_id: Feature ID to retrieve
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Feature object if found, None if not found
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
ValueError: If feature_id is invalid format
|
|
297
|
+
|
|
298
|
+
Performance: O(1) with cache, O(log n) from database
|
|
299
|
+
|
|
300
|
+
Examples:
|
|
301
|
+
>>> feature = repo.get("feat-001")
|
|
302
|
+
>>> feature2 = repo.get("feat-001")
|
|
303
|
+
>>> assert feature is feature2 # Same instance
|
|
304
|
+
"""
|
|
305
|
+
if not feature_id or not isinstance(feature_id, str):
|
|
306
|
+
raise ValueError(f"Invalid feature_id: {feature_id}")
|
|
307
|
+
|
|
308
|
+
# Check cache first
|
|
309
|
+
if feature_id in self._cache:
|
|
310
|
+
return self._cache[feature_id]
|
|
311
|
+
|
|
312
|
+
# Query database
|
|
313
|
+
conn = self._get_connection()
|
|
314
|
+
cursor = conn.execute("SELECT * FROM features WHERE id = ?", (feature_id,))
|
|
315
|
+
row = cursor.fetchone()
|
|
316
|
+
|
|
317
|
+
if not row:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
# Convert to Node and cache
|
|
321
|
+
node = self._row_to_node(row)
|
|
322
|
+
self._cache[feature_id] = node
|
|
323
|
+
return node
|
|
324
|
+
|
|
325
|
+
def list(self, filters: dict[str, Any] | None = None) -> list[Node]:
|
|
326
|
+
"""
|
|
327
|
+
List all features with optional filters.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
filters: Optional dict of attribute->value filters
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
List of Feature objects (empty list if no matches)
|
|
334
|
+
|
|
335
|
+
Raises:
|
|
336
|
+
FeatureValidationError: If filter keys are invalid
|
|
337
|
+
|
|
338
|
+
Performance: O(n) with SQL WHERE clauses
|
|
339
|
+
|
|
340
|
+
Examples:
|
|
341
|
+
>>> all_features = repo.list()
|
|
342
|
+
>>> todo_features = repo.list({"status": "todo"})
|
|
343
|
+
"""
|
|
344
|
+
if filters:
|
|
345
|
+
# Validate filter keys
|
|
346
|
+
valid_attrs = {
|
|
347
|
+
"status",
|
|
348
|
+
"priority",
|
|
349
|
+
"track_id",
|
|
350
|
+
"agent_assigned",
|
|
351
|
+
"type",
|
|
352
|
+
"title",
|
|
353
|
+
"id",
|
|
354
|
+
"created",
|
|
355
|
+
"updated",
|
|
356
|
+
}
|
|
357
|
+
for key in filters:
|
|
358
|
+
if key not in valid_attrs:
|
|
359
|
+
raise FeatureValidationError(f"Invalid filter attribute: {key}")
|
|
360
|
+
|
|
361
|
+
# Build query
|
|
362
|
+
where_clause, params = self._build_where_clause(filters or {})
|
|
363
|
+
sql = f"SELECT * FROM features {where_clause} ORDER BY created_at DESC"
|
|
364
|
+
|
|
365
|
+
# Execute query
|
|
366
|
+
conn = self._get_connection()
|
|
367
|
+
cursor = conn.execute(sql, params)
|
|
368
|
+
|
|
369
|
+
# Convert rows to nodes
|
|
370
|
+
results = []
|
|
371
|
+
for row in cursor.fetchall():
|
|
372
|
+
node_id = row["id"]
|
|
373
|
+
if node_id in self._cache:
|
|
374
|
+
results.append(self._cache[node_id])
|
|
375
|
+
else:
|
|
376
|
+
node = self._row_to_node(row)
|
|
377
|
+
self._cache[node_id] = node
|
|
378
|
+
results.append(node)
|
|
379
|
+
|
|
380
|
+
return results
|
|
381
|
+
|
|
382
|
+
def where(self, **kwargs: Any) -> RepositoryQuery:
|
|
383
|
+
"""Build a filtered query with chaining support."""
|
|
384
|
+
return SQLiteRepositoryQuery(self, kwargs)
|
|
385
|
+
|
|
386
|
+
def by_track(self, track_id: str) -> builtins.list[Node]:
|
|
387
|
+
"""Get all features belonging to a track."""
|
|
388
|
+
if not track_id:
|
|
389
|
+
raise ValueError("track_id cannot be empty")
|
|
390
|
+
return self.list({"track_id": track_id})
|
|
391
|
+
|
|
392
|
+
def by_status(self, status: str) -> builtins.list[Node]:
|
|
393
|
+
"""Filter features by status."""
|
|
394
|
+
return self.list({"status": status})
|
|
395
|
+
|
|
396
|
+
def by_priority(self, priority: str) -> builtins.list[Node]:
|
|
397
|
+
"""Filter features by priority."""
|
|
398
|
+
return self.list({"priority": priority})
|
|
399
|
+
|
|
400
|
+
def by_assigned_to(self, agent: str) -> builtins.list[Node]:
|
|
401
|
+
"""Get features assigned to an agent."""
|
|
402
|
+
return self.list({"agent_assigned": agent})
|
|
403
|
+
|
|
404
|
+
def batch_get(self, feature_ids: builtins.list[str]) -> builtins.list[Node]:
|
|
405
|
+
"""
|
|
406
|
+
Bulk retrieve multiple features.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
feature_ids: List of feature IDs
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
List of found features
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
ValueError: If feature_ids is not a list
|
|
416
|
+
|
|
417
|
+
Performance: O(k) where k = batch size
|
|
418
|
+
"""
|
|
419
|
+
if not isinstance(feature_ids, list):
|
|
420
|
+
raise ValueError("feature_ids must be a list")
|
|
421
|
+
|
|
422
|
+
if not feature_ids:
|
|
423
|
+
return []
|
|
424
|
+
|
|
425
|
+
# Build IN clause
|
|
426
|
+
placeholders = ",".join("?" * len(feature_ids))
|
|
427
|
+
sql = f"SELECT * FROM features WHERE id IN ({placeholders})"
|
|
428
|
+
|
|
429
|
+
conn = self._get_connection()
|
|
430
|
+
cursor = conn.execute(sql, feature_ids)
|
|
431
|
+
|
|
432
|
+
results = []
|
|
433
|
+
for row in cursor.fetchall():
|
|
434
|
+
node_id = row["id"]
|
|
435
|
+
if node_id in self._cache:
|
|
436
|
+
results.append(self._cache[node_id])
|
|
437
|
+
else:
|
|
438
|
+
node = self._row_to_node(row)
|
|
439
|
+
self._cache[node_id] = node
|
|
440
|
+
results.append(node)
|
|
441
|
+
|
|
442
|
+
return results
|
|
443
|
+
|
|
444
|
+
# ===== WRITE OPERATIONS =====
|
|
445
|
+
|
|
446
|
+
def create(self, title: str, **kwargs: Any) -> Node:
|
|
447
|
+
"""
|
|
448
|
+
Create new feature.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
title: Feature title (required)
|
|
452
|
+
**kwargs: Additional properties
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
Created Feature object (with generated ID)
|
|
456
|
+
|
|
457
|
+
Raises:
|
|
458
|
+
FeatureValidationError: If invalid data provided
|
|
459
|
+
|
|
460
|
+
Performance: O(1)
|
|
461
|
+
"""
|
|
462
|
+
if not title or not title.strip():
|
|
463
|
+
raise FeatureValidationError("Feature title cannot be empty")
|
|
464
|
+
|
|
465
|
+
# Generate ID if not provided
|
|
466
|
+
feature_id = kwargs.pop("id", None) or self._generate_id()
|
|
467
|
+
|
|
468
|
+
# Extract known fields from kwargs to avoid conflicts
|
|
469
|
+
node_type = kwargs.pop("type", "feature")
|
|
470
|
+
status = kwargs.pop("status", "todo")
|
|
471
|
+
priority = kwargs.pop("priority", "medium")
|
|
472
|
+
created = kwargs.pop("created", datetime.now())
|
|
473
|
+
updated = kwargs.pop("updated", datetime.now())
|
|
474
|
+
|
|
475
|
+
# Remove title from kwargs if present (already have it as parameter)
|
|
476
|
+
kwargs.pop("title", None)
|
|
477
|
+
|
|
478
|
+
# Create Node object
|
|
479
|
+
feature = Node(
|
|
480
|
+
id=feature_id,
|
|
481
|
+
title=title,
|
|
482
|
+
type=node_type,
|
|
483
|
+
status=status,
|
|
484
|
+
priority=priority,
|
|
485
|
+
created=created,
|
|
486
|
+
updated=updated,
|
|
487
|
+
**kwargs,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Validate and save
|
|
491
|
+
self._validate_feature(feature)
|
|
492
|
+
self.save(feature)
|
|
493
|
+
|
|
494
|
+
return feature
|
|
495
|
+
|
|
496
|
+
def save(self, feature: Node) -> Node:
|
|
497
|
+
"""
|
|
498
|
+
Save existing feature (update or insert).
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
feature: Feature object to save
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Saved feature (same instance)
|
|
505
|
+
|
|
506
|
+
Raises:
|
|
507
|
+
FeatureValidationError: If feature is invalid
|
|
508
|
+
|
|
509
|
+
Performance: O(1)
|
|
510
|
+
"""
|
|
511
|
+
self._validate_feature(feature)
|
|
512
|
+
|
|
513
|
+
# Update timestamp
|
|
514
|
+
feature.updated = datetime.now()
|
|
515
|
+
|
|
516
|
+
# Convert to row
|
|
517
|
+
row = self._node_to_row(feature)
|
|
518
|
+
|
|
519
|
+
# Insert or replace
|
|
520
|
+
conn = self._get_connection()
|
|
521
|
+
conn.execute(
|
|
522
|
+
"""
|
|
523
|
+
INSERT OR REPLACE INTO features (
|
|
524
|
+
id, type, title, description, status, priority,
|
|
525
|
+
assigned_to, track_id, created_at, updated_at,
|
|
526
|
+
completed_at, steps_total, steps_completed,
|
|
527
|
+
tags, metadata
|
|
528
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
529
|
+
""",
|
|
530
|
+
(
|
|
531
|
+
row["id"],
|
|
532
|
+
row["type"],
|
|
533
|
+
row["title"],
|
|
534
|
+
row["description"],
|
|
535
|
+
row["status"],
|
|
536
|
+
row["priority"],
|
|
537
|
+
row["assigned_to"],
|
|
538
|
+
row["track_id"],
|
|
539
|
+
row["created_at"],
|
|
540
|
+
row["updated_at"],
|
|
541
|
+
row["completed_at"],
|
|
542
|
+
row["steps_total"],
|
|
543
|
+
row["steps_completed"],
|
|
544
|
+
row["tags"],
|
|
545
|
+
row["metadata"],
|
|
546
|
+
),
|
|
547
|
+
)
|
|
548
|
+
conn.commit()
|
|
549
|
+
|
|
550
|
+
# Update cache
|
|
551
|
+
self._cache[feature.id] = feature
|
|
552
|
+
|
|
553
|
+
return feature
|
|
554
|
+
|
|
555
|
+
def batch_update(
|
|
556
|
+
self, feature_ids: builtins.list[str], updates: dict[str, Any]
|
|
557
|
+
) -> int:
|
|
558
|
+
"""
|
|
559
|
+
Vectorized batch update operation.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
feature_ids: List of feature IDs to update
|
|
563
|
+
updates: Dict of attribute->value to set
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
Number of features successfully updated
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
FeatureValidationError: If invalid updates
|
|
570
|
+
|
|
571
|
+
Performance: O(k) vectorized
|
|
572
|
+
"""
|
|
573
|
+
if not isinstance(feature_ids, list):
|
|
574
|
+
raise ValueError("feature_ids must be a list")
|
|
575
|
+
if not isinstance(updates, dict):
|
|
576
|
+
raise FeatureValidationError("updates must be a dict")
|
|
577
|
+
|
|
578
|
+
if not feature_ids:
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
# Map update keys to columns
|
|
582
|
+
column_map = {
|
|
583
|
+
"status": "status",
|
|
584
|
+
"priority": "priority",
|
|
585
|
+
"agent_assigned": "assigned_to",
|
|
586
|
+
"track_id": "track_id",
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
set_clauses = []
|
|
590
|
+
params = []
|
|
591
|
+
for key, value in updates.items():
|
|
592
|
+
if key in column_map:
|
|
593
|
+
set_clauses.append(f"{column_map[key]} = ?")
|
|
594
|
+
params.append(value)
|
|
595
|
+
|
|
596
|
+
if not set_clauses:
|
|
597
|
+
return 0
|
|
598
|
+
|
|
599
|
+
# Add updated_at
|
|
600
|
+
set_clauses.append("updated_at = ?")
|
|
601
|
+
params.append(datetime.now().isoformat())
|
|
602
|
+
|
|
603
|
+
# Add feature IDs
|
|
604
|
+
placeholders = ",".join("?" * len(feature_ids))
|
|
605
|
+
params.extend(feature_ids)
|
|
606
|
+
|
|
607
|
+
# Execute update
|
|
608
|
+
sql = f"""
|
|
609
|
+
UPDATE features
|
|
610
|
+
SET {", ".join(set_clauses)}
|
|
611
|
+
WHERE id IN ({placeholders})
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
conn = self._get_connection()
|
|
615
|
+
cursor = conn.execute(sql, params)
|
|
616
|
+
conn.commit()
|
|
617
|
+
|
|
618
|
+
# Invalidate cache for updated features
|
|
619
|
+
for fid in feature_ids:
|
|
620
|
+
self._cache.pop(fid, None)
|
|
621
|
+
|
|
622
|
+
return cursor.rowcount
|
|
623
|
+
|
|
624
|
+
def delete(self, feature_id: str) -> bool:
|
|
625
|
+
"""
|
|
626
|
+
Delete a feature by ID.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
feature_id: Feature ID to delete
|
|
630
|
+
|
|
631
|
+
Returns:
|
|
632
|
+
True if deleted, False if not found
|
|
633
|
+
|
|
634
|
+
Performance: O(1)
|
|
635
|
+
"""
|
|
636
|
+
if not feature_id:
|
|
637
|
+
raise FeatureValidationError("feature_id cannot be empty")
|
|
638
|
+
|
|
639
|
+
conn = self._get_connection()
|
|
640
|
+
cursor = conn.execute("DELETE FROM features WHERE id = ?", (feature_id,))
|
|
641
|
+
conn.commit()
|
|
642
|
+
|
|
643
|
+
# Remove from cache
|
|
644
|
+
self._cache.pop(feature_id, None)
|
|
645
|
+
|
|
646
|
+
return cursor.rowcount > 0
|
|
647
|
+
|
|
648
|
+
def batch_delete(self, feature_ids: builtins.list[str]) -> int:
|
|
649
|
+
"""
|
|
650
|
+
Delete multiple features.
|
|
651
|
+
|
|
652
|
+
Args:
|
|
653
|
+
feature_ids: List of feature IDs to delete
|
|
654
|
+
|
|
655
|
+
Returns:
|
|
656
|
+
Number of features successfully deleted
|
|
657
|
+
|
|
658
|
+
Performance: O(k) where k = batch size
|
|
659
|
+
"""
|
|
660
|
+
if not isinstance(feature_ids, list):
|
|
661
|
+
raise ValueError("feature_ids must be a list")
|
|
662
|
+
|
|
663
|
+
if not feature_ids:
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
placeholders = ",".join("?" * len(feature_ids))
|
|
667
|
+
sql = f"DELETE FROM features WHERE id IN ({placeholders})"
|
|
668
|
+
|
|
669
|
+
conn = self._get_connection()
|
|
670
|
+
cursor = conn.execute(sql, feature_ids)
|
|
671
|
+
conn.commit()
|
|
672
|
+
|
|
673
|
+
# Remove from cache
|
|
674
|
+
for fid in feature_ids:
|
|
675
|
+
self._cache.pop(fid, None)
|
|
676
|
+
|
|
677
|
+
return cursor.rowcount
|
|
678
|
+
|
|
679
|
+
# ===== ADVANCED QUERIES =====
|
|
680
|
+
|
|
681
|
+
def find_dependencies(self, feature_id: str) -> builtins.list[Node]:
|
|
682
|
+
"""
|
|
683
|
+
Find transitive feature dependencies.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
feature_id: Feature to find dependencies for
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
List of features this feature depends on
|
|
690
|
+
|
|
691
|
+
Raises:
|
|
692
|
+
FeatureNotFoundError: If feature not found
|
|
693
|
+
|
|
694
|
+
Performance: O(n) graph traversal
|
|
695
|
+
"""
|
|
696
|
+
feature = self.get(feature_id)
|
|
697
|
+
if not feature:
|
|
698
|
+
raise FeatureNotFoundError(feature_id)
|
|
699
|
+
|
|
700
|
+
dependencies = []
|
|
701
|
+
visited = set()
|
|
702
|
+
|
|
703
|
+
def traverse(f: Node) -> None:
|
|
704
|
+
if f.id in visited:
|
|
705
|
+
return
|
|
706
|
+
visited.add(f.id)
|
|
707
|
+
|
|
708
|
+
# Check edges for dependencies
|
|
709
|
+
if hasattr(f, "edges") and f.edges:
|
|
710
|
+
depends_on = (
|
|
711
|
+
f.edges.get("depends_on", []) if isinstance(f.edges, dict) else []
|
|
712
|
+
)
|
|
713
|
+
for edge in depends_on:
|
|
714
|
+
target_id = (
|
|
715
|
+
edge.target_id
|
|
716
|
+
if hasattr(edge, "target_id")
|
|
717
|
+
else edge.get("target_id")
|
|
718
|
+
if isinstance(edge, dict)
|
|
719
|
+
else None
|
|
720
|
+
)
|
|
721
|
+
if target_id:
|
|
722
|
+
dep = self.get(target_id)
|
|
723
|
+
if dep and dep not in dependencies:
|
|
724
|
+
dependencies.append(dep)
|
|
725
|
+
traverse(dep)
|
|
726
|
+
|
|
727
|
+
traverse(feature)
|
|
728
|
+
return dependencies
|
|
729
|
+
|
|
730
|
+
def find_blocking(self, feature_id: str) -> builtins.list[Node]:
|
|
731
|
+
"""
|
|
732
|
+
Find what blocks this feature.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
feature_id: Feature to find blockers for
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Features that depend on this feature
|
|
739
|
+
|
|
740
|
+
Raises:
|
|
741
|
+
FeatureNotFoundError: If feature not found
|
|
742
|
+
"""
|
|
743
|
+
feature = self.get(feature_id)
|
|
744
|
+
if not feature:
|
|
745
|
+
raise FeatureNotFoundError(feature_id)
|
|
746
|
+
|
|
747
|
+
# Query all features and check dependencies
|
|
748
|
+
all_features = self.list()
|
|
749
|
+
blocking = []
|
|
750
|
+
|
|
751
|
+
for f in all_features:
|
|
752
|
+
if hasattr(f, "edges") and f.edges:
|
|
753
|
+
depends_on = (
|
|
754
|
+
f.edges.get("depends_on", []) if isinstance(f.edges, dict) else []
|
|
755
|
+
)
|
|
756
|
+
for edge in depends_on:
|
|
757
|
+
target_id = (
|
|
758
|
+
edge.target_id
|
|
759
|
+
if hasattr(edge, "target_id")
|
|
760
|
+
else edge.get("target_id")
|
|
761
|
+
if isinstance(edge, dict)
|
|
762
|
+
else None
|
|
763
|
+
)
|
|
764
|
+
if target_id == feature_id:
|
|
765
|
+
blocking.append(f)
|
|
766
|
+
|
|
767
|
+
return blocking
|
|
768
|
+
|
|
769
|
+
def filter(self, predicate: Callable[[Node], bool]) -> builtins.list[Node]:
|
|
770
|
+
"""
|
|
771
|
+
Filter features with custom predicate function.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
predicate: Function that takes Feature and returns True/False
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
Features matching predicate
|
|
778
|
+
"""
|
|
779
|
+
all_features = self.list()
|
|
780
|
+
return [f for f in all_features if predicate(f)]
|
|
781
|
+
|
|
782
|
+
# ===== CACHE/LIFECYCLE MANAGEMENT =====
|
|
783
|
+
|
|
784
|
+
def invalidate_cache(self, feature_id: str | None = None) -> None:
|
|
785
|
+
"""
|
|
786
|
+
Invalidate cache for single feature or all features.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
feature_id: Specific feature to invalidate, or None for all
|
|
790
|
+
"""
|
|
791
|
+
if feature_id:
|
|
792
|
+
self._cache.pop(feature_id, None)
|
|
793
|
+
else:
|
|
794
|
+
self._cache.clear()
|
|
795
|
+
|
|
796
|
+
def reload(self) -> None:
|
|
797
|
+
"""
|
|
798
|
+
Force reload all features from storage.
|
|
799
|
+
|
|
800
|
+
Invalidates all caches and reloads from database.
|
|
801
|
+
"""
|
|
802
|
+
self._cache.clear()
|
|
803
|
+
# Cache will be populated on next query
|
|
804
|
+
|
|
805
|
+
@property
|
|
806
|
+
def auto_load(self) -> bool:
|
|
807
|
+
"""Whether auto-loading is enabled."""
|
|
808
|
+
return self._auto_load
|
|
809
|
+
|
|
810
|
+
@auto_load.setter
|
|
811
|
+
def auto_load(self, enabled: bool) -> None:
|
|
812
|
+
"""Enable/disable auto-loading."""
|
|
813
|
+
self._auto_load = enabled
|
|
814
|
+
|
|
815
|
+
# ===== UTILITY METHODS =====
|
|
816
|
+
|
|
817
|
+
def count(self, filters: dict[str, Any] | None = None) -> int:
|
|
818
|
+
"""
|
|
819
|
+
Count features matching filters.
|
|
820
|
+
|
|
821
|
+
Args:
|
|
822
|
+
filters: Optional filters
|
|
823
|
+
|
|
824
|
+
Returns:
|
|
825
|
+
Number of matching features
|
|
826
|
+
|
|
827
|
+
Performance: O(1) with SQL COUNT, O(n) without filters
|
|
828
|
+
"""
|
|
829
|
+
where_clause, params = self._build_where_clause(filters or {})
|
|
830
|
+
sql = f"SELECT COUNT(*) FROM features {where_clause}"
|
|
831
|
+
|
|
832
|
+
conn = self._get_connection()
|
|
833
|
+
cursor = conn.execute(sql, params)
|
|
834
|
+
result = cursor.fetchone()[0]
|
|
835
|
+
return int(result)
|
|
836
|
+
|
|
837
|
+
def exists(self, feature_id: str) -> bool:
|
|
838
|
+
"""
|
|
839
|
+
Check if feature exists without loading it.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
feature_id: Feature ID to check
|
|
843
|
+
|
|
844
|
+
Returns:
|
|
845
|
+
True if exists, False otherwise
|
|
846
|
+
|
|
847
|
+
Performance: O(1)
|
|
848
|
+
"""
|
|
849
|
+
# Check cache first
|
|
850
|
+
if feature_id in self._cache:
|
|
851
|
+
return True
|
|
852
|
+
|
|
853
|
+
# Check database
|
|
854
|
+
conn = self._get_connection()
|
|
855
|
+
cursor = conn.execute(
|
|
856
|
+
"SELECT 1 FROM features WHERE id = ? LIMIT 1", (feature_id,)
|
|
857
|
+
)
|
|
858
|
+
return cursor.fetchone() is not None
|