Phase 1: Edge schema consolidation infrastructure (Decision 004)

Add metadata and thread_id columns to dependencies table to support:
- Edge metadata: JSON blob for type-specific data (similarity scores, etc.)
- Thread queries: O(1) conversation threading via thread_id

Changes:
- New migration 020_edge_consolidation.go
- Updated Dependency struct with Metadata and ThreadID fields
- Added new entity types: authored-by, assigned-to, approved-by
- Relaxed DependencyType validation (any non-empty string ≤50 chars)
- Added IsWellKnown() and AffectsReadyWork() methods
- Updated SQL queries to include new columns
- Updated tests for new behavior

This enables HOP knowledge graph requirements and Reddit-style threading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-18 01:17:57 -08:00
parent b4a6ee4f5f
commit d390aa8834
6 changed files with 191 additions and 22 deletions

View File

@@ -21,7 +21,7 @@ const (
func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency, actor string) error {
// Validate dependency type
if !dep.Type.IsValid() {
return fmt.Errorf("invalid dependency type: %s (must be blocks, related, parent-child, or discovered-from)", dep.Type)
return fmt.Errorf("invalid dependency type: %q (must be non-empty string, max 50 chars)", dep.Type)
}
// Validate that both issues exist
@@ -125,11 +125,11 @@ func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency
dep.IssueID, dep.DependsOnID, dep.IssueID)
}
// Insert dependency
// Insert dependency (including metadata and thread_id for edge consolidation - Decision 004)
_, err = tx.ExecContext(ctx, `
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by)
VALUES (?, ?, ?, ?, ?)
`, dep.IssueID, dep.DependsOnID, dep.Type, dep.CreatedAt, dep.CreatedBy)
INSERT INTO dependencies (issue_id, depends_on_id, type, created_at, created_by, metadata, thread_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, dep.IssueID, dep.DependsOnID, dep.Type, dep.CreatedAt, dep.CreatedBy, dep.Metadata, dep.ThreadID)
if err != nil {
return fmt.Errorf("failed to add dependency: %w", err)
}
@@ -151,8 +151,8 @@ func (s *SQLiteStorage) AddDependency(ctx context.Context, dep *types.Dependency
}
// Invalidate blocked issues cache since dependencies changed (bd-5qim)
// Only invalidate for 'blocks' and 'parent-child' types since they affect blocking
if dep.Type == types.DepBlocks || dep.Type == types.DepParentChild {
// Only invalidate for types that affect ready work calculation
if dep.Type.AffectsReadyWork() {
if err := s.invalidateBlockedCache(ctx, tx); err != nil {
return fmt.Errorf("failed to invalidate blocked cache: %w", err)
}
@@ -174,7 +174,7 @@ func (s *SQLiteStorage) RemoveDependency(ctx context.Context, issueID, dependsOn
// Store whether cache needs invalidation before deletion
needsCacheInvalidation := false
if err == nil {
needsCacheInvalidation = (depType == types.DepBlocks || depType == types.DepParentChild)
needsCacheInvalidation = depType.AffectsReadyWork()
}
result, err := tx.ExecContext(ctx, `
@@ -369,7 +369,8 @@ func (s *SQLiteStorage) GetDependencyCounts(ctx context.Context, issueIDs []stri
// GetDependencyRecords returns raw dependency records for an issue
func (s *SQLiteStorage) GetDependencyRecords(ctx context.Context, issueID string) ([]*types.Dependency, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT issue_id, depends_on_id, type, created_at, created_by
SELECT issue_id, depends_on_id, type, created_at, created_by,
COALESCE(metadata, '{}') as metadata, COALESCE(thread_id, '') as thread_id
FROM dependencies
WHERE issue_id = ?
ORDER BY created_at ASC
@@ -388,6 +389,8 @@ func (s *SQLiteStorage) GetDependencyRecords(ctx context.Context, issueID string
&dep.Type,
&dep.CreatedAt,
&dep.CreatedBy,
&dep.Metadata,
&dep.ThreadID,
)
if err != nil {
return nil, fmt.Errorf("failed to scan dependency: %w", err)
@@ -402,7 +405,8 @@ func (s *SQLiteStorage) GetDependencyRecords(ctx context.Context, issueID string
// This is optimized for bulk export operations to avoid N+1 queries
func (s *SQLiteStorage) GetAllDependencyRecords(ctx context.Context) (map[string][]*types.Dependency, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT issue_id, depends_on_id, type, created_at, created_by
SELECT issue_id, depends_on_id, type, created_at, created_by,
COALESCE(metadata, '{}') as metadata, COALESCE(thread_id, '') as thread_id
FROM dependencies
ORDER BY issue_id, created_at ASC
`)
@@ -421,6 +425,8 @@ func (s *SQLiteStorage) GetAllDependencyRecords(ctx context.Context) (map[string
&dep.Type,
&dep.CreatedAt,
&dep.CreatedBy,
&dep.Metadata,
&dep.ThreadID,
)
if err != nil {
return nil, fmt.Errorf("failed to scan dependency: %w", err)

View File

@@ -36,6 +36,7 @@ var migrationsList = []Migration{
{"close_reason_column", migrations.MigrateCloseReasonColumn},
{"tombstone_columns", migrations.MigrateTombstoneColumns},
{"messaging_fields", migrations.MigrateMessagingFields},
{"edge_consolidation", migrations.MigrateEdgeConsolidation},
}
// MigrationInfo contains metadata about a migration for inspection
@@ -79,6 +80,7 @@ func getMigrationDescription(name string) string {
"close_reason_column": "Adds close_reason column to issues table for storing closure explanations (bd-uyu)",
"tombstone_columns": "Adds tombstone columns (deleted_at, deleted_by, delete_reason, original_type) for inline soft-delete (bd-vw8)",
"messaging_fields": "Adds messaging fields (sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by) for inter-agent communication (bd-kwro)",
"edge_consolidation": "Adds metadata and thread_id columns to dependencies table for edge schema consolidation (Decision 004)",
}
if desc, ok := descriptions[name]; ok {

View File

@@ -0,0 +1,60 @@
package migrations
import (
"database/sql"
"fmt"
)
// MigrateEdgeConsolidation adds metadata and thread_id columns to the dependencies table.
// This is Phase 1 of the Edge Schema Consolidation (Decision 004):
// - metadata: JSON blob for type-specific edge data (similarity scores, reasons, etc.)
// - thread_id: For efficient conversation threading queries
//
// These columns enable:
// - Edge metadata without schema changes (extensibility)
// - O(1) thread queries for Reddit-style conversations
// - HOP knowledge graph foundation
func MigrateEdgeConsolidation(db *sql.DB) error {
columns := []struct {
name string
definition string
}{
{"metadata", "TEXT DEFAULT '{}'"},
{"thread_id", "TEXT DEFAULT ''"},
}
for _, col := range columns {
var columnExists bool
err := db.QueryRow(`
SELECT COUNT(*) > 0
FROM pragma_table_info('dependencies')
WHERE name = ?
`, col.name).Scan(&columnExists)
if err != nil {
return fmt.Errorf("failed to check %s column: %w", col.name, err)
}
if columnExists {
continue
}
_, err = db.Exec(fmt.Sprintf(`ALTER TABLE dependencies ADD COLUMN %s %s`, col.name, col.definition))
if err != nil {
return fmt.Errorf("failed to add %s column: %w", col.name, err)
}
}
// Add index for thread queries - only index non-empty thread_ids
_, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_dependencies_thread ON dependencies(thread_id) WHERE thread_id != ''`)
if err != nil {
return fmt.Errorf("failed to create thread_id index: %w", err)
}
// Add composite index for finding all edges in a thread by type
_, err = db.Exec(`CREATE INDEX IF NOT EXISTS idx_dependencies_thread_type ON dependencies(thread_id, type) WHERE thread_id != ''`)
if err != nil {
return fmt.Errorf("failed to create thread_type index: %w", err)
}
return nil
}

View File

@@ -43,13 +43,15 @@ CREATE INDEX IF NOT EXISTS idx_issues_assignee ON issues(assignee);
CREATE INDEX IF NOT EXISTS idx_issues_created_at ON issues(created_at);
-- Note: idx_issues_external_ref is created in migrations/002_external_ref_column.go
-- Dependencies table
-- Dependencies table (edge schema - Decision 004)
CREATE TABLE IF NOT EXISTS dependencies (
issue_id TEXT NOT NULL,
depends_on_id TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'blocks',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
metadata TEXT DEFAULT '{}', -- JSON blob for type-specific edge data
thread_id TEXT DEFAULT '', -- For efficient conversation threading queries
PRIMARY KEY (issue_id, depends_on_id),
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (depends_on_id) REFERENCES issues(id) ON DELETE CASCADE
@@ -59,6 +61,8 @@ CREATE INDEX IF NOT EXISTS idx_dependencies_issue ON dependencies(issue_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type ON dependencies(depends_on_id, type);
CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on_type_issue ON dependencies(depends_on_id, type, issue_id);
CREATE INDEX IF NOT EXISTS idx_dependencies_thread ON dependencies(thread_id) WHERE thread_id != '';
CREATE INDEX IF NOT EXISTS idx_dependencies_thread_type ON dependencies(thread_id, type) WHERE thread_id != '';
-- Labels table
CREATE TABLE IF NOT EXISTS labels (