Files
beads/internal/storage/sqlite/migrations.go
Peter Chanthamynavong d371baf2ca feat(dates): add --due and --defer timestamp options with natural language parsing (#847)
* feat(dates): add due date schema and --due flag

- Add due_at and defer_until columns to issues table via migration 035
- Implement --due flag on create command with ISO date parsing
- Extend RPC protocol and daemon to pass DueAt from CLI to storage
- Display DueAt and DeferUntil in show command output
- Update Issue type with new date fields

Users can now set due dates when creating issues, enabling deadline-based
task management.

* feat(dates): add compact duration parser (+6h, +1d, +2w)

- Create internal/timeparsing package with layered parser architecture
- Implement parseCompactDuration with regex pattern [+-]?\d+[hdwmy]
- Add comprehensive test suite (22 cases) for duration parsing
- Integrate into create.go with fallback to ISO format

Supports hours (h), days (d), weeks (w), months (m), and years (y).
Negative values allowed for past dates.

* feat(dates): add NLP parsing for natural language dates

Integrate olebedev/when library for natural language time expressions.
The layered parser now handles: compact duration → absolute formats → NLP.

Changes:
- Add olebedev/when dependency for NLP parsing
- Implement ParseNaturalLanguage and ParseRelativeTime functions
- Reorder layers: absolute formats before NLP to avoid misinterpretation
- Simplify create.go to use unified ParseRelativeTime
- Add comprehensive NLP test coverage (22 test cases)

Supports: tomorrow, next monday, in 3 days, 3 days ago

* feat(dates): add --defer flag to create/update/defer commands

Add time-based deferral support alongside existing status-based defer.
Issues can now be hidden from bd ready until a specific time.

Changes:
- Add --defer flag to bd create (sets defer_until on creation)
- Add --due and --defer flags to bd update (modify existing issues)
- Add --until flag to bd defer (combines status=deferred with defer_until)
- Add DueAt/DeferUntil fields to UpdateArgs in protocol.go

Supports: +1h, tomorrow, next monday, 2025-01-15

* feat(dates): add defer_until filtering to ready command

Add time-based deferral support to bd ready:

- Add --include-deferred flag to show issues with future defer_until
- Filter out issues where defer_until > now by default
- Update undefer to clear defer_until alongside status change
- Add IncludeDeferred to WorkFilter and RPC ReadyArgs

Part of GH#820: Relative Date Parsing (Phase 5)

* feat(dates): add polish and tests for relative date parsing

Add user-facing warnings when defer date is in the past to help catch
common mistakes. Expand help text with format examples and document
the olebedev/when September parsing quirk.

Tests:
- TestCreateSuite/WithDueAt, WithDeferUntil, WithBothDueAndDefer
- TestReadyWorkDeferUntil (ExcludesFutureDeferredByDefault, IncludeDeferredShowsAll)

Docs:
- CLAUDE.md quick reference updated with new flags
- Help text examples for --due, --defer on create/update

Closes: Phase 6 of beads-820-relative-dates spec

* feat(list): add time-based query filters for defer/due dates

Add --deferred, --defer-before, --defer-after, --due-before, --due-after,
and --overdue flags to bd list command. All date filters now support
relative time expressions (+6h, tomorrow, next monday) via the
timeparsing package.

Filters:
- --deferred: issues with defer_until set
- --defer-before/after: filter by defer_until date range
- --due-before/after: filter by due_at date range
- --overdue: due_at in past AND status != closed

Existing date filters (--created-after, etc.) now also support relative
time expressions through updated parseTimeFlag().

* build(nix): update vendorHash for olebedev/when dependency

The olebedev/when library was added for natural language date parsing
(GH#820). This changes go.sum, requiring an updated vendorHash in the
Nix flake configuration.
2026-01-01 20:06:13 -08:00

182 lines
9.1 KiB
Go

// Package sqlite - database migrations
package sqlite
import (
"database/sql"
"fmt"
"github.com/steveyegge/beads/internal/storage/sqlite/migrations"
)
// Migration represents a single database migration
type Migration struct {
Name string
Func func(*sql.DB) error
}
// migrations is the ordered list of all migrations to run
// Migrations are run in order during database initialization
var migrationsList = []Migration{
{"dirty_issues_table", migrations.MigrateDirtyIssuesTable},
{"external_ref_column", migrations.MigrateExternalRefColumn},
{"composite_indexes", migrations.MigrateCompositeIndexes},
{"closed_at_constraint", migrations.MigrateClosedAtConstraint},
{"compaction_columns", migrations.MigrateCompactionColumns},
{"snapshots_table", migrations.MigrateSnapshotsTable},
{"compaction_config", migrations.MigrateCompactionConfig},
{"compacted_at_commit_column", migrations.MigrateCompactedAtCommitColumn},
{"export_hashes_table", migrations.MigrateExportHashesTable},
{"content_hash_column", migrations.MigrateContentHashColumn},
{"external_ref_unique", migrations.MigrateExternalRefUnique},
{"source_repo_column", migrations.MigrateSourceRepoColumn},
{"repo_mtimes_table", migrations.MigrateRepoMtimesTable},
{"child_counters_table", migrations.MigrateChildCountersTable},
{"blocked_issues_cache", migrations.MigrateBlockedIssuesCache},
{"orphan_detection", migrations.MigrateOrphanDetection},
{"close_reason_column", migrations.MigrateCloseReasonColumn},
{"tombstone_columns", migrations.MigrateTombstoneColumns},
{"messaging_fields", migrations.MigrateMessagingFields},
{"edge_consolidation", migrations.MigrateEdgeConsolidation},
{"migrate_edge_fields", migrations.MigrateEdgeFields},
{"drop_edge_columns", migrations.MigrateDropEdgeColumns},
{"pinned_column", migrations.MigratePinnedColumn},
{"is_template_column", migrations.MigrateIsTemplateColumn},
{"remove_depends_on_fk", migrations.MigrateRemoveDependsOnFK},
{"additional_indexes", migrations.MigrateAdditionalIndexes},
{"gate_columns", migrations.MigrateGateColumns},
{"tombstone_closed_at", migrations.MigrateTombstoneClosedAt},
{"created_by_column", migrations.MigrateCreatedByColumn},
{"agent_fields", migrations.MigrateAgentFields},
{"mol_type_column", migrations.MigrateMolTypeColumn},
{"hooked_status_migration", migrations.MigrateHookedStatus},
{"event_fields", migrations.MigrateEventFields},
{"closed_by_session_column", migrations.MigrateClosedBySessionColumn},
{"due_defer_columns", migrations.MigrateDueDeferColumns},
}
// MigrationInfo contains metadata about a migration for inspection
type MigrationInfo struct {
Name string `json:"name"`
Description string `json:"description"`
}
// ListMigrations returns list of all registered migrations with descriptions
// Note: This returns ALL registered migrations, not just pending ones (all are idempotent)
func ListMigrations() []MigrationInfo {
result := make([]MigrationInfo, len(migrationsList))
for i, m := range migrationsList {
result[i] = MigrationInfo{
Name: m.Name,
Description: getMigrationDescription(m.Name),
}
}
return result
}
// getMigrationDescription returns a human-readable description for a migration
func getMigrationDescription(name string) string {
descriptions := map[string]string{
"dirty_issues_table": "Adds dirty_issues table for auto-export tracking",
"external_ref_column": "Adds external_ref column to issues table",
"composite_indexes": "Adds composite indexes for better query performance",
"closed_at_constraint": "Adds constraint ensuring closed issues have closed_at timestamp",
"compaction_columns": "Adds compaction tracking columns (compacted_at, compacted_at_commit)",
"snapshots_table": "Adds snapshots table for issue history",
"compaction_config": "Adds config entries for compaction",
"compacted_at_commit_column": "Adds compacted_at_commit to snapshots table",
"export_hashes_table": "Adds export_hashes table for idempotent exports",
"content_hash_column": "Adds content_hash column for collision resolution",
"external_ref_unique": "Adds UNIQUE constraint on external_ref column",
"source_repo_column": "Adds source_repo column for multi-repo support",
"repo_mtimes_table": "Adds repo_mtimes table for multi-repo hydration caching",
"child_counters_table": "Adds child_counters table for hierarchical ID generation with ON DELETE CASCADE",
"blocked_issues_cache": "Adds blocked_issues_cache table for GetReadyWork performance optimization",
"orphan_detection": "Detects orphaned child issues and logs them for user action",
"close_reason_column": "Adds close_reason column to issues table for storing closure explanations",
"tombstone_columns": "Adds tombstone columns (deleted_at, deleted_by, delete_reason, original_type) for inline soft-delete",
"messaging_fields": "Adds messaging fields (sender, ephemeral, replies_to, relates_to, duplicate_of, superseded_by) for inter-agent communication",
"edge_consolidation": "Adds metadata and thread_id columns to dependencies table for edge schema consolidation (Decision 004)",
"migrate_edge_fields": "Migrates existing issue fields (replies_to, relates_to, duplicate_of, superseded_by) to dependency edges (Decision 004 Phase 3)",
"drop_edge_columns": "Drops deprecated edge columns (replies_to, relates_to, duplicate_of, superseded_by) from issues table (Decision 004 Phase 4)",
"pinned_column": "Adds pinned column for persistent context markers",
"is_template_column": "Adds is_template column for template molecules",
"remove_depends_on_fk": "Removes FK constraint on depends_on_id to allow external references",
"additional_indexes": "Adds performance optimization indexes for common query patterns",
"gate_columns": "Adds gate columns (await_type, await_id, timeout_ns, waiters) for async coordination",
"tombstone_closed_at": "Preserves closed_at timestamp when issues become tombstones",
"created_by_column": "Adds created_by column to track issue creator",
"agent_fields": "Adds agent identity fields (hook_bead, role_bead, agent_state, etc.) for agent-as-bead pattern",
"mol_type_column": "Adds mol_type column for molecule type classification (swarm/patrol/work)",
"hooked_status_migration": "Migrates blocked hooked issues to in_progress status",
"event_fields": "Adds event fields (event_kind, actor, target, payload) for operational state change beads",
"closed_by_session_column": "Adds closed_by_session column for tracking which Claude Code session closed an issue",
"due_defer_columns": "Adds due_at and defer_until columns for time-based task scheduling (GH#820)",
}
if desc, ok := descriptions[name]; ok {
return desc
}
return "Unknown migration"
}
// RunMigrations executes all registered migrations in order with invariant checking.
// Uses EXCLUSIVE transaction to prevent race conditions when multiple processes
// open the database simultaneously (GH#720).
func RunMigrations(db *sql.DB) error {
// Disable foreign keys BEFORE starting the transaction.
// PRAGMA foreign_keys must be called when no transaction is active (SQLite limitation).
// Some migrations (022, 025) drop/recreate tables and need foreign keys off
// to prevent ON DELETE CASCADE from deleting related data.
_, err := db.Exec("PRAGMA foreign_keys = OFF")
if err != nil {
return fmt.Errorf("failed to disable foreign keys for migrations: %w", err)
}
defer func() { _, _ = db.Exec("PRAGMA foreign_keys = ON") }()
// Acquire EXCLUSIVE lock to serialize migrations across processes.
// Without this, parallel processes can race on check-then-modify operations
// (e.g., checking if a column exists then adding it), causing "duplicate column" errors.
_, err = db.Exec("BEGIN EXCLUSIVE")
if err != nil {
return fmt.Errorf("failed to acquire exclusive lock for migrations: %w", err)
}
// Ensure we release the lock on any exit path
committed := false
defer func() {
if !committed {
_, _ = db.Exec("ROLLBACK")
}
}()
// Pre-migration cleanup: remove orphaned refs that would fail invariant checks.
// This prevents the chicken-and-egg problem where the database can't open
// due to orphans left behind by tombstone deletion (see bd-eko4).
if _, _, err := CleanOrphanedRefs(db); err != nil {
return fmt.Errorf("pre-migration orphan cleanup failed: %w", err)
}
snapshot, err := captureSnapshot(db)
if err != nil {
return fmt.Errorf("failed to capture pre-migration snapshot: %w", err)
}
for _, migration := range migrationsList {
if err := migration.Func(db); err != nil {
return fmt.Errorf("migration %s failed: %w", migration.Name, err)
}
}
if err := verifyInvariants(db, snapshot); err != nil {
return fmt.Errorf("post-migration validation failed: %w", err)
}
// Commit the transaction
if _, err := db.Exec("COMMIT"); err != nil {
return fmt.Errorf("failed to commit migrations: %w", err)
}
committed = true
return nil
}