fix(dolt): parse timestamps from TEXT columns instead of direct time.Time scan

The Dolt storage was scanning created_at and updated_at directly into
time.Time fields, but SQLite stores these as TEXT strings. The Go SQLite
driver cannot automatically convert TEXT to time.Time.

Added parseTimeString() helper and fixed all scan functions:
- issues.go: scanIssue()
- dependencies.go: scanIssueRow()
- history.go: GetIssueHistory(), GetIssueAsOf()
- transaction.go: scanIssueTx()

Fixes bd-4dqmy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beads/crew/lydia
2026-01-21 20:48:34 -08:00
committed by Steve Yegge
parent b63e7b8cf0
commit 2cc96197c0
5 changed files with 88 additions and 5 deletions

View File

@@ -523,6 +523,7 @@ func (s *DoltStore) GetIssuesByIDs(ctx context.Context, ids []string) ([]*types.
// scanIssueRow scans a single issue from a rows result // scanIssueRow scans a single issue from a rows result
func scanIssueRow(rows *sql.Rows) (*types.Issue, error) { func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
var issue types.Issue var issue types.Issue
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime
var estimatedMinutes, originalSize, timeoutNs sql.NullInt64 var estimatedMinutes, originalSize, timeoutNs sql.NullInt64
var assignee, externalRef, compactedAtCommit, owner sql.NullString var assignee, externalRef, compactedAtCommit, owner sql.NullString
@@ -538,7 +539,7 @@ func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &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, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType, &deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &pinned, &isTemplate, &crystallizes, &sender, &ephemeral, &pinned, &isTemplate, &crystallizes,
@@ -551,6 +552,14 @@ func scanIssueRow(rows *sql.Rows) (*types.Issue, error) {
return nil, fmt.Errorf("failed to scan issue row: %w", err) return nil, fmt.Errorf("failed to scan issue row: %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)
}
// Map nullable fields // Map nullable fields
if contentHash.Valid { if contentHash.Valid {
issue.ContentHash = contentHash.String issue.ContentHash = contentHash.String

View File

@@ -74,6 +74,7 @@ func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*Iss
for rows.Next() { for rows.Next() {
var h IssueHistory var h IssueHistory
var issue types.Issue var issue types.Issue
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
var closedAt sql.NullTime var closedAt sql.NullTime
var assignee, owner, createdBy, closeReason, molType sql.NullString var assignee, owner, createdBy, closeReason, molType sql.NullString
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
@@ -82,13 +83,21 @@ func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*Iss
if err := rows.Scan( if err := rows.Scan(
&issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes,
&issue.Status, &issue.Priority, &issue.IssueType, &assignee, &owner, &createdBy, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &owner, &createdBy,
&estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &closeReason, &estimatedMinutes, &createdAtStr, &updatedAtStr, &closedAt, &closeReason,
&pinned, &molType, &pinned, &molType,
&h.CommitHash, &h.Committer, &h.CommitDate, &h.CommitHash, &h.Committer, &h.CommitDate,
); err != nil { ); err != nil {
return nil, fmt.Errorf("failed to scan history: %w", err) return nil, fmt.Errorf("failed to scan history: %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 { if closedAt.Valid {
issue.ClosedAt = &closedAt.Time issue.ClosedAt = &closedAt.Time
} }
@@ -130,6 +139,7 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string
} }
var issue types.Issue var issue types.Issue
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
var closedAt sql.NullTime var closedAt sql.NullTime
var assignee, owner, contentHash sql.NullString var assignee, owner, contentHash sql.NullString
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
@@ -144,7 +154,7 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string
err := s.db.QueryRowContext(ctx, query, issueID).Scan( err := s.db.QueryRowContext(ctx, query, issueID).Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt,
) )
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -154,6 +164,14 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string
return nil, fmt.Errorf("failed to get issue as of %s: %w", ref, err) return nil, fmt.Errorf("failed to get issue as of %s: %w", ref, 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 { if contentHash.Valid {
issue.ContentHash = contentHash.String issue.ContentHash = contentHash.String
} }

View File

@@ -410,6 +410,7 @@ func insertIssue(ctx context.Context, tx *sql.Tx, issue *types.Issue) error {
func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) { func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) {
var issue types.Issue var issue types.Issue
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime
var estimatedMinutes, originalSize, timeoutNs sql.NullInt64 var estimatedMinutes, originalSize, timeoutNs sql.NullInt64
var assignee, externalRef, compactedAtCommit, owner sql.NullString var assignee, externalRef, compactedAtCommit, owner sql.NullString
@@ -439,7 +440,7 @@ func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error)
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &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, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason,
&deletedAt, &deletedBy, &deleteReason, &originalType, &deletedAt, &deletedBy, &deleteReason, &originalType,
&sender, &ephemeral, &pinned, &isTemplate, &crystallizes, &sender, &ephemeral, &pinned, &isTemplate, &crystallizes,
@@ -457,6 +458,14 @@ func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error)
return nil, fmt.Errorf("failed to get issue: %w", err) 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)
}
// Map nullable fields // Map nullable fields
if contentHash.Valid { if contentHash.Valid {
issue.ContentHash = contentHash.String issue.ContentHash = contentHash.String

View File

@@ -376,6 +376,7 @@ func insertIssueTx(ctx context.Context, tx *sql.Tx, issue *types.Issue) error {
func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, error) { func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, error) {
var issue types.Issue var issue types.Issue
var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually
var closedAt sql.NullTime var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64 var estimatedMinutes sql.NullInt64
var assignee, owner, contentHash sql.NullString var assignee, owner, contentHash sql.NullString
@@ -392,7 +393,7 @@ func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, erro
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt,
&ephemeral, &pinned, &isTemplate, &crystallizes, &ephemeral, &pinned, &isTemplate, &crystallizes,
) )
@@ -403,6 +404,14 @@ func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, erro
return nil, err return nil, 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 { if contentHash.Valid {
issue.ContentHash = contentHash.String issue.ContentHash = contentHash.String
} }

View File

@@ -0,0 +1,38 @@
package dolt
import (
"database/sql"
"time"
)
// parseTimeString parses a time string from database TEXT columns (non-nullable).
// Used for required timestamp fields like created_at/updated_at when stored as TEXT.
// 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
}
// parseNullableTimeString parses a nullable time string from database TEXT columns.
// For columns declared as TEXT (not DATETIME), 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
}