From 18b1eb2e07f00ab5476a3d1c2cb29693d7037ed3 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sat, 13 Dec 2025 07:13:40 -0800 Subject: [PATCH] fix(sqlite): handle deleted_at TEXT column scanning properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deleted_at column was defined as TEXT in the schema but code was trying to scan into sql.NullTime. The ncruces/go-sqlite3 driver only auto-converts TEXT to time.Time for columns declared as DATETIME/DATE/ TIME/TIMESTAMP. For TEXT columns, it returns raw strings which sql.NullTime.Scan() cannot handle. Added parseNullableTimeString() helper that manually parses time strings and changed all deletedAt variables from sql.NullTime to sql.NullString. Fixes import failure: "sql: Scan error on column index 22, name deleted_at: unsupported Scan, storing driver.Value type string into type *time.Time" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/storage/sqlite/dependencies.go | 12 ++++------ internal/storage/sqlite/queries.go | 29 ++++++++++++++++++------- internal/storage/sqlite/ready.go | 6 ++--- internal/storage/sqlite/transaction.go | 6 ++--- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/internal/storage/sqlite/dependencies.go b/internal/storage/sqlite/dependencies.go index aa6dea96..e7d8a7ab 100644 --- a/internal/storage/sqlite/dependencies.go +++ b/internal/storage/sqlite/dependencies.go @@ -691,7 +691,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type var externalRef sql.NullString var sourceRepo sql.NullString var closeReason sql.NullString - var deletedAt sql.NullTime + var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString @@ -729,9 +729,7 @@ func (s *SQLiteStorage) scanIssues(ctx context.Context, rows *sql.Rows) ([]*type if closeReason.Valid { issue.CloseReason = closeReason.String } - if deletedAt.Valid { - issue.DeletedAt = &deletedAt.Time - } + issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String } @@ -773,7 +771,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * var assignee sql.NullString var externalRef sql.NullString var sourceRepo sql.NullString - var deletedAt sql.NullTime + var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString @@ -810,9 +808,7 @@ func (s *SQLiteStorage) scanIssuesWithDependencyType(ctx context.Context, rows * if sourceRepo.Valid { issue.SourceRepo = sourceRepo.String } - if deletedAt.Valid { - issue.DeletedAt = &deletedAt.Time - } + issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index 1fc59c6e..bc48003b 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -11,6 +11,23 @@ import ( "github.com/steveyegge/beads/internal/types" ) +// parseNullableTimeString parses a nullable time string from database TEXT columns. +// The ncruces/go-sqlite3 driver only auto-converts TEXT→time.Time for columns declared +// as DATETIME/DATE/TIME/TIMESTAMP. For TEXT columns (like deleted_at), we must parse manually. +// Supports RFC3339, RFC3339Nano, and SQLite's native format. +func parseNullableTimeString(ns sql.NullString) *time.Time { + if !ns.Valid || ns.String == "" { + return nil + } + // 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, ns.String); err == nil { + return &t + } + } + return nil // Unparseable - shouldn't happen with valid data +} + // REMOVED (bd-8e05): getNextIDForPrefix and AllocateNextID - sequential ID generation // no longer needed with hash-based IDs // Migration functions moved to migrations.go (bd-fc2d, bd-b245) @@ -158,7 +175,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, var originalSize sql.NullInt64 var sourceRepo sql.NullString var closeReason sql.NullString - var deletedAt sql.NullTime + var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString @@ -220,9 +237,7 @@ func (s *SQLiteStorage) GetIssue(ctx context.Context, id string) (*types.Issue, if closeReason.Valid { issue.CloseReason = closeReason.String } - if deletedAt.Valid { - issue.DeletedAt = &deletedAt.Time - } + issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String } @@ -331,7 +346,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s var compactedAtCommit sql.NullString var sourceRepo sql.NullString var closeReason sql.NullString - var deletedAt sql.NullTime + var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString @@ -391,9 +406,7 @@ func (s *SQLiteStorage) GetIssueByExternalRef(ctx context.Context, externalRef s if closeReason.Valid { issue.CloseReason = closeReason.String } - if deletedAt.Valid { - issue.DeletedAt = &deletedAt.Time - } + issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String } diff --git a/internal/storage/sqlite/ready.go b/internal/storage/sqlite/ready.go index e4d2a297..1865a96f 100644 --- a/internal/storage/sqlite/ready.go +++ b/internal/storage/sqlite/ready.go @@ -170,7 +170,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi var compactedAtCommit sql.NullString var originalSize sql.NullInt64 var closeReason sql.NullString - var deletedAt sql.NullTime + var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString @@ -221,9 +221,7 @@ func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFi if closeReason.Valid { issue.CloseReason = closeReason.String } - if deletedAt.Valid { - issue.DeletedAt = &deletedAt.Time - } + issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String } diff --git a/internal/storage/sqlite/transaction.go b/internal/storage/sqlite/transaction.go index 9689ee8b..96b89f10 100644 --- a/internal/storage/sqlite/transaction.go +++ b/internal/storage/sqlite/transaction.go @@ -1068,7 +1068,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { var sourceRepo sql.NullString var compactedAtCommit sql.NullString var closeReason sql.NullString - var deletedAt sql.NullTime + var deletedAt sql.NullString // TEXT column, not DATETIME - must parse manually var deletedBy sql.NullString var deleteReason sql.NullString var originalType sql.NullString @@ -1116,9 +1116,7 @@ func scanIssueRow(row scanner) (*types.Issue, error) { if closeReason.Valid { issue.CloseReason = closeReason.String } - if deletedAt.Valid { - issue.DeletedAt = &deletedAt.Time - } + issue.DeletedAt = parseNullableTimeString(deletedAt) if deletedBy.Valid { issue.DeletedBy = deletedBy.String }