From 458fb7197a9088f2723d7934a788336b7aef2c25 Mon Sep 17 00:00:00 2001 From: beads/crew/collins Date: Tue, 20 Jan 2026 19:07:24 -0800 Subject: [PATCH] fix(sqlite): handle text timestamps in scan for cross-driver compatibility The ncruces/go-sqlite3 driver does not always auto-convert TEXT columns to time.Time. This caused scan errors on updated_at/created_at fields, blocking witness startup. Fix: Scan timestamps into sql.NullString and parse with parseTimeString() helper that handles RFC3339Nano, RFC3339, and SQLite native formats. Fixes: bd-4dqmy Co-Authored-By: Claude Opus 4.5 --- internal/storage/sqlite/dependencies.go | 24 +++++++++++++-- internal/storage/sqlite/queries.go | 40 +++++++++++++++++++++++-- internal/storage/sqlite/ready.go | 24 +++++++++++++-- internal/storage/sqlite/transaction.go | 12 +++++++- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index 09d8b32d..7ec74bd9 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -885,6 +885,8 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type // First pass: scan all issues for rows.Next() { var issue types.Issue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var contentHash sql.NullString var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 @@ -927,7 +929,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &closeReason, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, @@ -938,6 +940,14 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type return nil, fmt.Errorf("failed to scan issue: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } @@ -1065,6 +1075,8 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * var results []*types.IssueWithDependencyMetadata for rows.Next() { var issue types.Issue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var contentHash sql.NullString var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 @@ -1096,7 +1108,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef, &sourceRepo, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &crystallizes, &awaitType, &awaitID, &timeoutNs, &waiters, @@ -1106,6 +1118,14 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * return nil, fmt.Errorf("failed to scan issue with dependency type: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index c86bdd7e..ee2e21cc 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -33,6 +33,22 @@ func parseNullableTimeString(ns sql.NullString) *time.Time { return nil // Unparseable - shouldn't happen with valid data } +// parseTimeString parses a time string from database TEXT columns (non-nullable). +// Similar to parseNullableTimeString but for required timestamp fields like created_at/updated_at. +// Returns zero time if parsing fails, which maintains backwards compatibility. +func parseTimeString(s string) time.Time { + if s == "" { + return time.Time{} + } + // Try RFC3339Nano first (more precise), then RFC3339, then SQLite format + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t + } + } + return time.Time{} // Unparseable - shouldn't happen with valid data +} + // parseJSONStringArray parses a JSON string array from database TEXT column. // Returns empty slice if the string is empty or invalid JSON. func parseJSONStringArray(s string) []string { @@ -292,6 +308,8 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, defer s.reconnectMu.RUnlock() var issue types.Issue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString @@ -356,7 +374,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &crystallizes, @@ -373,6 +391,14 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, return nil, fmt.Errorf("failed to get issue: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } @@ -581,6 +607,8 @@ func (s *SQLiteStorage) GetCloseReasonsForIssues(ctx context.Context, issueIDs [ // GetIssueByExternalRef retrieves an issue by external reference func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef string) (*types.Issue, error) { var issue types.Issue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString @@ -625,7 +653,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRefCol, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRefCol, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &crystallizes, @@ -639,6 +667,14 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s return nil, fmt.Errorf("failed to get issue by external_ref: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index 5c63b4a5..3ffed1e6 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -346,6 +346,8 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi var issues []*types.Issue for rows.Next() { var issue types.Issue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString @@ -378,7 +380,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, + &createdAtStr, &updatedAtStr, &closedAt, &externalRef, &sourceRepo, &compactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &ephemeral, &pinned, &isTemplate, @@ -388,6 +390,14 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi return nil, fmt.Errorf("failed to scan stale issue: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } @@ -564,6 +574,8 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context, filter types.WorkF var blocked []*types.BlockedIssue for rows.Next() { var issue types.BlockedIssue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee sql.NullString @@ -575,13 +587,21 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context, filter types.WorkF &issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &issue.UpdatedAt, &closedAt, &externalRef, &sourceRepo, &issue.BlockedByCount, + &createdAtStr, &issue.CreatedBy, &updatedAtStr, &closedAt, &externalRef, &sourceRepo, &issue.BlockedByCount, &blockerIDsStr, ) if err != nil { return nil, fmt.Errorf("failed to scan blocked issue: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if closedAt.Valid { issue.ClosedAt = &closedAt.Time } diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 9a063804..b540ab41 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -1291,6 +1291,8 @@ type scanner interface { // consistent scanning of issue rows. func scanIssueRow(row scanner) (*types.Issue, error) { var issue types.Issue + var createdAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility + var updatedAtStr sql.NullString // TEXT column - must parse manually for cross-driver compatibility var contentHash sql.NullString var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 @@ -1336,7 +1338,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &wisp, &pinned, &isTemplate, &crystallizes, @@ -1348,6 +1350,14 @@ func scanIssueRow(row scanner) (*types.Issue, error) { return nil, fmt.Errorf("failed to scan issue: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String }