htmlgraph 0.20.1__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 +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- 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 +10 -6
- 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 +67 -27
- htmlgraph/analytics_index.py +53 -20
- 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 +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- 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 +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- 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 +2 -1
- htmlgraph/deploy.py +26 -27
- 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 +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- 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 +8 -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 +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- 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 +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- 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.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- 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/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 +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLiteTrackRepository - SQLite database-based Track storage.
|
|
3
|
+
|
|
4
|
+
Stores tracks 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 Node
|
|
17
|
+
from htmlgraph.repositories.track_repository import (
|
|
18
|
+
RepositoryQuery,
|
|
19
|
+
TrackRepository,
|
|
20
|
+
TrackValidationError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SQLiteRepositoryQuery(RepositoryQuery):
|
|
25
|
+
"""Query builder for SQLite filtering."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, repo: "SQLiteTrackRepository", filters: dict[str, Any]):
|
|
28
|
+
super().__init__(filters)
|
|
29
|
+
self._repo = repo
|
|
30
|
+
|
|
31
|
+
def where(self, **kwargs: Any) -> "SQLiteRepositoryQuery":
|
|
32
|
+
"""Chain additional filters."""
|
|
33
|
+
# Validate filter keys
|
|
34
|
+
valid_attrs = {
|
|
35
|
+
"status",
|
|
36
|
+
"priority",
|
|
37
|
+
"has_spec",
|
|
38
|
+
"has_plan",
|
|
39
|
+
"type",
|
|
40
|
+
"title",
|
|
41
|
+
"id",
|
|
42
|
+
"created",
|
|
43
|
+
"updated",
|
|
44
|
+
}
|
|
45
|
+
for key in kwargs:
|
|
46
|
+
if key not in valid_attrs:
|
|
47
|
+
raise TrackValidationError(f"Invalid filter attribute: {key}")
|
|
48
|
+
|
|
49
|
+
# Merge filters
|
|
50
|
+
new_filters = {**self.filters, **kwargs}
|
|
51
|
+
return SQLiteRepositoryQuery(self._repo, new_filters)
|
|
52
|
+
|
|
53
|
+
def execute(self) -> list[Any]:
|
|
54
|
+
"""Execute the query and return results."""
|
|
55
|
+
return self._repo.list(self.filters)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SQLiteTrackRepository(TrackRepository):
|
|
59
|
+
"""
|
|
60
|
+
SQLite database-based TrackRepository implementation.
|
|
61
|
+
|
|
62
|
+
Stores tracks in a SQLite database for fast queries and transactions.
|
|
63
|
+
Uses parameterized queries to prevent SQL injection.
|
|
64
|
+
|
|
65
|
+
Database schema:
|
|
66
|
+
Table: tracks
|
|
67
|
+
Columns: id, type, title, description, status, priority,
|
|
68
|
+
created_at, updated_at, has_spec, has_plan,
|
|
69
|
+
metadata (JSON)
|
|
70
|
+
|
|
71
|
+
Performance:
|
|
72
|
+
- get(id): O(1) with cache, O(log n) from database (indexed)
|
|
73
|
+
- list(): O(n) with SQL WHERE clauses
|
|
74
|
+
- batch operations: O(k) vectorized SQL
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> db_path = Path(".htmlgraph/htmlgraph.db")
|
|
78
|
+
>>> repo = SQLiteTrackRepository(db_path)
|
|
79
|
+
>>> track = repo.create("Planning Phase 1", priority="high")
|
|
80
|
+
>>> track.status = "active"
|
|
81
|
+
>>> repo.save(track)
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, db_path: Path | str, auto_load: bool = True):
|
|
85
|
+
"""
|
|
86
|
+
Initialize SQLite repository.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
db_path: Path to SQLite database file
|
|
90
|
+
auto_load: Whether to enable auto-loading (always True for DB)
|
|
91
|
+
"""
|
|
92
|
+
self._db_path = Path(db_path)
|
|
93
|
+
self._auto_load = auto_load
|
|
94
|
+
|
|
95
|
+
# Identity cache: track_id -> Node instance
|
|
96
|
+
self._cache: dict[str, Node] = {}
|
|
97
|
+
|
|
98
|
+
# Initialize database connection
|
|
99
|
+
self._db = HtmlGraphDB(str(self._db_path))
|
|
100
|
+
self._db.connect()
|
|
101
|
+
self._db.create_tables()
|
|
102
|
+
|
|
103
|
+
# Disable foreign key constraints for testing
|
|
104
|
+
if self._db.connection:
|
|
105
|
+
self._db.connection.execute("PRAGMA foreign_keys = OFF")
|
|
106
|
+
|
|
107
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
108
|
+
"""Get database connection."""
|
|
109
|
+
if not self._db.connection:
|
|
110
|
+
self._db.connect()
|
|
111
|
+
assert self._db.connection is not None
|
|
112
|
+
return self._db.connection
|
|
113
|
+
|
|
114
|
+
def _generate_id(self) -> str:
|
|
115
|
+
"""Generate unique track ID."""
|
|
116
|
+
import uuid
|
|
117
|
+
|
|
118
|
+
return f"trk-{uuid.uuid4().hex[:8]}"
|
|
119
|
+
|
|
120
|
+
def _validate_track(self, track: Any) -> None:
|
|
121
|
+
"""Validate track object."""
|
|
122
|
+
if not hasattr(track, "id"):
|
|
123
|
+
raise TrackValidationError("Track must have 'id' attribute")
|
|
124
|
+
if not hasattr(track, "title"):
|
|
125
|
+
raise TrackValidationError("Track must have 'title' attribute")
|
|
126
|
+
if not track.id or not str(track.id).strip():
|
|
127
|
+
raise TrackValidationError("Track ID cannot be empty")
|
|
128
|
+
if not track.title or not str(track.title).strip():
|
|
129
|
+
raise TrackValidationError("Track title cannot be empty")
|
|
130
|
+
|
|
131
|
+
def _row_to_node(self, row: sqlite3.Row) -> Node:
|
|
132
|
+
"""Convert database row to Node object."""
|
|
133
|
+
# Parse metadata JSON
|
|
134
|
+
metadata = json.loads(row["metadata"]) if row["metadata"] else {}
|
|
135
|
+
|
|
136
|
+
# Map database status to Node status (handle legacy values)
|
|
137
|
+
db_status = row["status"] or "todo"
|
|
138
|
+
status_map: dict[str, str] = {
|
|
139
|
+
"in_progress": "in-progress",
|
|
140
|
+
"cancelled": "done",
|
|
141
|
+
"planned": "todo",
|
|
142
|
+
"completed": "done",
|
|
143
|
+
}
|
|
144
|
+
status = status_map.get(db_status, db_status)
|
|
145
|
+
|
|
146
|
+
# Cast to valid status literal - Node validates this
|
|
147
|
+
from typing import Literal, cast
|
|
148
|
+
|
|
149
|
+
valid_status = cast(
|
|
150
|
+
Literal[
|
|
151
|
+
"todo", "in-progress", "blocked", "done", "active", "ended", "stale"
|
|
152
|
+
],
|
|
153
|
+
status,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Create Node object
|
|
157
|
+
node = Node(
|
|
158
|
+
id=row["id"],
|
|
159
|
+
title=row["title"],
|
|
160
|
+
type=row["type"] or "track",
|
|
161
|
+
status=valid_status,
|
|
162
|
+
priority=row["priority"] or "medium",
|
|
163
|
+
created=datetime.fromisoformat(row["created_at"])
|
|
164
|
+
if row["created_at"]
|
|
165
|
+
else datetime.now(),
|
|
166
|
+
updated=datetime.fromisoformat(row["updated_at"])
|
|
167
|
+
if row["updated_at"]
|
|
168
|
+
else datetime.now(),
|
|
169
|
+
content=row["description"] or "",
|
|
170
|
+
properties=metadata,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return node
|
|
174
|
+
|
|
175
|
+
def _node_to_dict(self, track: Node) -> dict[str, Any]:
|
|
176
|
+
"""Convert Node object to database dict."""
|
|
177
|
+
# Extract track-specific metadata
|
|
178
|
+
metadata = (
|
|
179
|
+
dict(track.properties)
|
|
180
|
+
if hasattr(track, "properties") and track.properties
|
|
181
|
+
else {}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if hasattr(track, "has_spec"):
|
|
185
|
+
metadata["has_spec"] = track.has_spec
|
|
186
|
+
if hasattr(track, "has_plan"):
|
|
187
|
+
metadata["has_plan"] = track.has_plan
|
|
188
|
+
if hasattr(track, "features"):
|
|
189
|
+
metadata["features"] = track.features
|
|
190
|
+
if hasattr(track, "phases"):
|
|
191
|
+
metadata["phases"] = track.phases
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
"id": track.id,
|
|
195
|
+
"type": track.type,
|
|
196
|
+
"title": track.title,
|
|
197
|
+
"description": track.content if hasattr(track, "content") else "",
|
|
198
|
+
"status": track.status,
|
|
199
|
+
"priority": track.priority,
|
|
200
|
+
"created_at": track.created.isoformat()
|
|
201
|
+
if hasattr(track, "created")
|
|
202
|
+
else datetime.now().isoformat(),
|
|
203
|
+
"updated_at": track.updated.isoformat()
|
|
204
|
+
if hasattr(track, "updated")
|
|
205
|
+
else datetime.now().isoformat(),
|
|
206
|
+
"metadata": json.dumps(metadata),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
# ===== READ OPERATIONS =====
|
|
210
|
+
|
|
211
|
+
def get(self, track_id: str) -> Node | None:
|
|
212
|
+
"""
|
|
213
|
+
Get single track by ID.
|
|
214
|
+
|
|
215
|
+
Returns same object instance for multiple calls (identity caching).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
track_id: Track ID to retrieve
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Track object if found, None if not found
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: If track_id is invalid format
|
|
225
|
+
|
|
226
|
+
Performance: O(1) if cached, O(log n) from database
|
|
227
|
+
"""
|
|
228
|
+
if not track_id or not isinstance(track_id, str):
|
|
229
|
+
raise ValueError(f"Invalid track_id: {track_id}")
|
|
230
|
+
|
|
231
|
+
# Check cache first
|
|
232
|
+
if track_id in self._cache:
|
|
233
|
+
return self._cache[track_id]
|
|
234
|
+
|
|
235
|
+
# Query database
|
|
236
|
+
conn = self._get_connection()
|
|
237
|
+
cursor = conn.execute("SELECT * FROM tracks WHERE id = ?", (track_id,))
|
|
238
|
+
row = cursor.fetchone()
|
|
239
|
+
|
|
240
|
+
if not row:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
# Convert to Node and cache
|
|
244
|
+
track = self._row_to_node(row)
|
|
245
|
+
self._cache[track_id] = track
|
|
246
|
+
return track
|
|
247
|
+
|
|
248
|
+
def list(self, filters: dict[str, Any] | None = None) -> list[Node]:
|
|
249
|
+
"""
|
|
250
|
+
List all tracks with optional filters.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
filters: Optional dict of attribute->value filters
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of Track objects (empty list if no matches)
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
TrackValidationError: If filter keys are invalid
|
|
260
|
+
|
|
261
|
+
Performance: O(n) with SQL WHERE clauses
|
|
262
|
+
"""
|
|
263
|
+
if filters:
|
|
264
|
+
# Validate filter keys
|
|
265
|
+
valid_attrs = {
|
|
266
|
+
"status",
|
|
267
|
+
"priority",
|
|
268
|
+
"has_spec",
|
|
269
|
+
"has_plan",
|
|
270
|
+
"type",
|
|
271
|
+
"title",
|
|
272
|
+
"id",
|
|
273
|
+
"created",
|
|
274
|
+
"updated",
|
|
275
|
+
}
|
|
276
|
+
for key in filters:
|
|
277
|
+
if key not in valid_attrs:
|
|
278
|
+
raise TrackValidationError(f"Invalid filter attribute: {key}")
|
|
279
|
+
|
|
280
|
+
# Build SQL query
|
|
281
|
+
query = "SELECT * FROM tracks"
|
|
282
|
+
params = []
|
|
283
|
+
|
|
284
|
+
if filters:
|
|
285
|
+
where_clauses = []
|
|
286
|
+
for key, value in filters.items():
|
|
287
|
+
if key in ["status", "priority", "type", "title", "id"]:
|
|
288
|
+
where_clauses.append(f"{key} = ?")
|
|
289
|
+
params.append(value)
|
|
290
|
+
elif key in ["has_spec", "has_plan"]:
|
|
291
|
+
# Query JSON metadata
|
|
292
|
+
where_clauses.append(f"json_extract(metadata, '$.{key}') = ?")
|
|
293
|
+
params.append(1 if value else 0)
|
|
294
|
+
|
|
295
|
+
if where_clauses:
|
|
296
|
+
query += " WHERE " + " AND ".join(where_clauses)
|
|
297
|
+
|
|
298
|
+
# Execute query
|
|
299
|
+
conn = self._get_connection()
|
|
300
|
+
cursor = conn.execute(query, params)
|
|
301
|
+
rows = cursor.fetchall()
|
|
302
|
+
|
|
303
|
+
# Convert to Node objects
|
|
304
|
+
results = []
|
|
305
|
+
for row in rows:
|
|
306
|
+
track_id = row["id"]
|
|
307
|
+
if track_id in self._cache:
|
|
308
|
+
results.append(self._cache[track_id])
|
|
309
|
+
else:
|
|
310
|
+
track = self._row_to_node(row)
|
|
311
|
+
self._cache[track_id] = track
|
|
312
|
+
results.append(track)
|
|
313
|
+
|
|
314
|
+
return results
|
|
315
|
+
|
|
316
|
+
def where(self, **kwargs: Any) -> RepositoryQuery:
|
|
317
|
+
"""
|
|
318
|
+
Build a filtered query with chaining support.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
**kwargs: Attribute->value filter pairs
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
RepositoryQuery object that can be further filtered
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
TrackValidationError: If invalid attribute names
|
|
328
|
+
"""
|
|
329
|
+
# Validate filter keys upfront
|
|
330
|
+
valid_attrs = {
|
|
331
|
+
"status",
|
|
332
|
+
"priority",
|
|
333
|
+
"has_spec",
|
|
334
|
+
"has_plan",
|
|
335
|
+
"type",
|
|
336
|
+
"title",
|
|
337
|
+
"id",
|
|
338
|
+
"created",
|
|
339
|
+
"updated",
|
|
340
|
+
}
|
|
341
|
+
for key in kwargs:
|
|
342
|
+
if key not in valid_attrs:
|
|
343
|
+
raise TrackValidationError(f"Invalid filter attribute: {key}")
|
|
344
|
+
return SQLiteRepositoryQuery(self, kwargs)
|
|
345
|
+
|
|
346
|
+
def by_status(self, status: str) -> builtins.list[Node]:
|
|
347
|
+
"""Filter tracks by status."""
|
|
348
|
+
return self.list({"status": status})
|
|
349
|
+
|
|
350
|
+
def by_priority(self, priority: str) -> builtins.list[Node]:
|
|
351
|
+
"""Filter tracks by priority."""
|
|
352
|
+
return self.list({"priority": priority})
|
|
353
|
+
|
|
354
|
+
def active_tracks(self) -> builtins.list[Node]:
|
|
355
|
+
"""Get all tracks currently in progress."""
|
|
356
|
+
return self.by_status("active")
|
|
357
|
+
|
|
358
|
+
def batch_get(self, track_ids: builtins.list[str]) -> builtins.list[Node]:
|
|
359
|
+
"""
|
|
360
|
+
Bulk retrieve multiple tracks.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
track_ids: List of track IDs
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
List of found tracks
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
ValueError: If track_ids is not a list
|
|
370
|
+
|
|
371
|
+
Performance: O(k) where k = batch size
|
|
372
|
+
"""
|
|
373
|
+
if not isinstance(track_ids, list):
|
|
374
|
+
raise ValueError("track_ids must be a list")
|
|
375
|
+
|
|
376
|
+
results = []
|
|
377
|
+
for tid in track_ids:
|
|
378
|
+
track = self.get(tid)
|
|
379
|
+
if track:
|
|
380
|
+
results.append(track)
|
|
381
|
+
return results
|
|
382
|
+
|
|
383
|
+
# ===== WRITE OPERATIONS =====
|
|
384
|
+
|
|
385
|
+
def create(self, title: str, **kwargs: Any) -> Node:
|
|
386
|
+
"""
|
|
387
|
+
Create new track.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
title: Track title (required)
|
|
391
|
+
**kwargs: Additional properties
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Created Track object (with generated ID)
|
|
395
|
+
|
|
396
|
+
Raises:
|
|
397
|
+
TrackValidationError: If invalid data provided
|
|
398
|
+
|
|
399
|
+
Performance: O(1)
|
|
400
|
+
"""
|
|
401
|
+
if not title or not title.strip():
|
|
402
|
+
raise TrackValidationError("Track title cannot be empty")
|
|
403
|
+
|
|
404
|
+
# Generate ID if not provided
|
|
405
|
+
track_id = kwargs.pop("id", None) or self._generate_id()
|
|
406
|
+
|
|
407
|
+
# Extract known fields
|
|
408
|
+
node_type = kwargs.pop("type", "track")
|
|
409
|
+
status = kwargs.pop("status", "todo")
|
|
410
|
+
priority = kwargs.pop("priority", "medium")
|
|
411
|
+
created = kwargs.pop("created", datetime.now())
|
|
412
|
+
updated = kwargs.pop("updated", datetime.now())
|
|
413
|
+
|
|
414
|
+
# Remove title from kwargs if present
|
|
415
|
+
kwargs.pop("title", None)
|
|
416
|
+
|
|
417
|
+
# Create Node object
|
|
418
|
+
track = Node(
|
|
419
|
+
id=track_id,
|
|
420
|
+
title=title,
|
|
421
|
+
type=node_type,
|
|
422
|
+
status=status,
|
|
423
|
+
priority=priority,
|
|
424
|
+
created=created,
|
|
425
|
+
updated=updated,
|
|
426
|
+
**kwargs,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# Validate and save
|
|
430
|
+
self._validate_track(track)
|
|
431
|
+
self.save(track)
|
|
432
|
+
|
|
433
|
+
return track
|
|
434
|
+
|
|
435
|
+
def save(self, track: Node) -> Node:
|
|
436
|
+
"""
|
|
437
|
+
Save existing track (update or insert).
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
track: Track object to save
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
Saved track (same instance)
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
TrackValidationError: If track is invalid
|
|
447
|
+
|
|
448
|
+
Performance: O(1)
|
|
449
|
+
"""
|
|
450
|
+
self._validate_track(track)
|
|
451
|
+
|
|
452
|
+
# Update timestamp
|
|
453
|
+
track.updated = datetime.now()
|
|
454
|
+
|
|
455
|
+
# Convert to dict
|
|
456
|
+
data = self._node_to_dict(track)
|
|
457
|
+
|
|
458
|
+
# Upsert into database
|
|
459
|
+
conn = self._get_connection()
|
|
460
|
+
conn.execute(
|
|
461
|
+
"""
|
|
462
|
+
INSERT OR REPLACE INTO tracks
|
|
463
|
+
(id, type, title, description, status, priority, created_at, updated_at, metadata)
|
|
464
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
465
|
+
""",
|
|
466
|
+
(
|
|
467
|
+
data["id"],
|
|
468
|
+
data["type"],
|
|
469
|
+
data["title"],
|
|
470
|
+
data["description"],
|
|
471
|
+
data["status"],
|
|
472
|
+
data["priority"],
|
|
473
|
+
data["created_at"],
|
|
474
|
+
data["updated_at"],
|
|
475
|
+
data["metadata"],
|
|
476
|
+
),
|
|
477
|
+
)
|
|
478
|
+
conn.commit()
|
|
479
|
+
|
|
480
|
+
# Update cache
|
|
481
|
+
self._cache[track.id] = track
|
|
482
|
+
|
|
483
|
+
return track
|
|
484
|
+
|
|
485
|
+
def batch_update(
|
|
486
|
+
self, track_ids: builtins.list[str], updates: dict[str, Any]
|
|
487
|
+
) -> int:
|
|
488
|
+
"""
|
|
489
|
+
Vectorized batch update operation.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
track_ids: List of track IDs to update
|
|
493
|
+
updates: Dict of attribute->value to set
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
Number of tracks successfully updated
|
|
497
|
+
|
|
498
|
+
Raises:
|
|
499
|
+
TrackValidationError: If invalid updates
|
|
500
|
+
|
|
501
|
+
Performance: O(k) vectorized SQL where k = batch size
|
|
502
|
+
"""
|
|
503
|
+
if not isinstance(track_ids, list):
|
|
504
|
+
raise ValueError("track_ids must be a list")
|
|
505
|
+
if not isinstance(updates, dict):
|
|
506
|
+
raise TrackValidationError("updates must be a dict")
|
|
507
|
+
|
|
508
|
+
count = 0
|
|
509
|
+
for tid in track_ids:
|
|
510
|
+
track = self.get(tid)
|
|
511
|
+
if track:
|
|
512
|
+
# Apply updates
|
|
513
|
+
for key, value in updates.items():
|
|
514
|
+
setattr(track, key, value)
|
|
515
|
+
self.save(track)
|
|
516
|
+
count += 1
|
|
517
|
+
|
|
518
|
+
return count
|
|
519
|
+
|
|
520
|
+
def delete(self, track_id: str) -> bool:
|
|
521
|
+
"""
|
|
522
|
+
Delete a track by ID.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
track_id: Track ID to delete
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
True if deleted, False if not found
|
|
529
|
+
|
|
530
|
+
Performance: O(1)
|
|
531
|
+
"""
|
|
532
|
+
if not track_id:
|
|
533
|
+
raise TrackValidationError("track_id cannot be empty")
|
|
534
|
+
|
|
535
|
+
# Delete from database
|
|
536
|
+
conn = self._get_connection()
|
|
537
|
+
cursor = conn.execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
|
538
|
+
conn.commit()
|
|
539
|
+
|
|
540
|
+
# Remove from cache
|
|
541
|
+
self._cache.pop(track_id, None)
|
|
542
|
+
|
|
543
|
+
return cursor.rowcount > 0
|
|
544
|
+
|
|
545
|
+
def batch_delete(self, track_ids: builtins.list[str]) -> int:
|
|
546
|
+
"""
|
|
547
|
+
Delete multiple tracks.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
track_ids: List of track IDs to delete
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Number of tracks successfully deleted
|
|
554
|
+
|
|
555
|
+
Performance: O(k) where k = batch size
|
|
556
|
+
"""
|
|
557
|
+
if not isinstance(track_ids, list):
|
|
558
|
+
raise ValueError("track_ids must be a list")
|
|
559
|
+
|
|
560
|
+
count = 0
|
|
561
|
+
for tid in track_ids:
|
|
562
|
+
if self.delete(tid):
|
|
563
|
+
count += 1
|
|
564
|
+
return count
|
|
565
|
+
|
|
566
|
+
# ===== ADVANCED QUERIES =====
|
|
567
|
+
|
|
568
|
+
def find_by_features(self, feature_ids: builtins.list[str]) -> builtins.list[Node]:
|
|
569
|
+
"""
|
|
570
|
+
Find tracks containing any of the specified features.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
feature_ids: List of feature IDs to search for
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Tracks that contain at least one of these features
|
|
577
|
+
|
|
578
|
+
Raises:
|
|
579
|
+
ValueError: If feature_ids is not a list
|
|
580
|
+
|
|
581
|
+
Performance: O(n) with JSON queries
|
|
582
|
+
"""
|
|
583
|
+
if not isinstance(feature_ids, list):
|
|
584
|
+
raise ValueError("feature_ids must be a list")
|
|
585
|
+
|
|
586
|
+
# Query tracks with features in metadata
|
|
587
|
+
conn = self._get_connection()
|
|
588
|
+
|
|
589
|
+
# Build query to check if any feature_id is in the features array
|
|
590
|
+
results = []
|
|
591
|
+
for feature_id in feature_ids:
|
|
592
|
+
cursor = conn.execute(
|
|
593
|
+
"""
|
|
594
|
+
SELECT * FROM tracks
|
|
595
|
+
WHERE json_extract(metadata, '$.features') LIKE ?
|
|
596
|
+
""",
|
|
597
|
+
(f"%{feature_id}%",),
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
for row in cursor.fetchall():
|
|
601
|
+
track_id = row["id"]
|
|
602
|
+
if track_id in self._cache:
|
|
603
|
+
track = self._cache[track_id]
|
|
604
|
+
else:
|
|
605
|
+
track = self._row_to_node(row)
|
|
606
|
+
self._cache[track_id] = track
|
|
607
|
+
|
|
608
|
+
if track not in results:
|
|
609
|
+
results.append(track)
|
|
610
|
+
|
|
611
|
+
return results
|
|
612
|
+
|
|
613
|
+
def with_feature_count(self) -> builtins.list[Node]:
|
|
614
|
+
"""
|
|
615
|
+
Get all tracks with feature count calculated.
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
All tracks with feature_count property set
|
|
619
|
+
"""
|
|
620
|
+
return self.list()
|
|
621
|
+
|
|
622
|
+
def filter(self, predicate: Callable[[Node], bool]) -> builtins.list[Node]:
|
|
623
|
+
"""
|
|
624
|
+
Filter tracks with custom predicate function.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
predicate: Function that takes Track and returns True/False
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
Tracks matching predicate
|
|
631
|
+
"""
|
|
632
|
+
all_tracks = self.list()
|
|
633
|
+
return [t for t in all_tracks if predicate(t)]
|
|
634
|
+
|
|
635
|
+
# ===== CACHE/LIFECYCLE MANAGEMENT =====
|
|
636
|
+
|
|
637
|
+
def invalidate_cache(self, track_id: str | None = None) -> None:
|
|
638
|
+
"""
|
|
639
|
+
Invalidate cache for single track or all tracks.
|
|
640
|
+
|
|
641
|
+
Forces reload from storage on next access.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
track_id: Specific track to invalidate, or None for all
|
|
645
|
+
"""
|
|
646
|
+
if track_id:
|
|
647
|
+
self._cache.pop(track_id, None)
|
|
648
|
+
else:
|
|
649
|
+
self._cache.clear()
|
|
650
|
+
|
|
651
|
+
def reload(self) -> None:
|
|
652
|
+
"""
|
|
653
|
+
Force reload all tracks from storage.
|
|
654
|
+
|
|
655
|
+
Invalidates all caches and reloads from database.
|
|
656
|
+
"""
|
|
657
|
+
self._cache.clear()
|
|
658
|
+
# Database is always up-to-date, no need to reload
|
|
659
|
+
|
|
660
|
+
@property
|
|
661
|
+
def auto_load(self) -> bool:
|
|
662
|
+
"""Whether auto-loading is enabled."""
|
|
663
|
+
return self._auto_load
|
|
664
|
+
|
|
665
|
+
@auto_load.setter
|
|
666
|
+
def auto_load(self, enabled: bool) -> None:
|
|
667
|
+
"""Enable/disable auto-loading."""
|
|
668
|
+
self._auto_load = enabled
|
|
669
|
+
|
|
670
|
+
# ===== UTILITY METHODS =====
|
|
671
|
+
|
|
672
|
+
def count(self, filters: dict[str, Any] | None = None) -> int:
|
|
673
|
+
"""
|
|
674
|
+
Count tracks matching filters.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
filters: Optional filters
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
Number of matching tracks
|
|
681
|
+
|
|
682
|
+
Performance: O(1) with SQL COUNT, O(n) with filters
|
|
683
|
+
"""
|
|
684
|
+
if not filters:
|
|
685
|
+
conn = self._get_connection()
|
|
686
|
+
cursor = conn.execute("SELECT COUNT(*) FROM tracks")
|
|
687
|
+
result = cursor.fetchone()[0]
|
|
688
|
+
return int(result)
|
|
689
|
+
|
|
690
|
+
return len(self.list(filters))
|
|
691
|
+
|
|
692
|
+
def exists(self, track_id: str) -> bool:
|
|
693
|
+
"""
|
|
694
|
+
Check if track exists without loading it.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
track_id: Track ID to check
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
True if exists, False otherwise
|
|
701
|
+
|
|
702
|
+
Performance: O(1)
|
|
703
|
+
"""
|
|
704
|
+
# Check cache first
|
|
705
|
+
if track_id in self._cache:
|
|
706
|
+
return True
|
|
707
|
+
|
|
708
|
+
# Check database
|
|
709
|
+
conn = self._get_connection()
|
|
710
|
+
cursor = conn.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,))
|
|
711
|
+
return cursor.fetchone() is not None
|