Skip to content

Commit 481e9ba

Browse files
adnaanclaude
andauthored
feat: add database console and scheduled tasks (Roadmap 4.7 + 4.4) (#284)
* feat: add database console and scheduled tasks (Roadmap 4.7 + 4.4) Database Console (4.7): - `lvt console` (or `lvt db`) opens interactive sqlite3 shell - Auto-detects database via DATABASE_PATH env var or app.db in cwd - Passes -header -column flags for readable output - Prints helpful commands (.tables, .schema, .quit) - Clear error if sqlite3 not installed Scheduled Tasks (4.4): - `lvt gen task <name> --schedule <interval>` scaffolds recurring tasks - Built on existing River job queue (lvt gen queue) - Schedule shortcuts: @hourly, @daily, @Weekly, @every 5m - Generates task handler in app/jobs/<name>.go - Injects PeriodicJobs() function into worker.go - Updates River client config with PeriodicJobs in main.go - Tasks run automatically when app starts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add FTS5 full-text search for resources (Roadmap 4.2) New --searchable flag on lvt gen resource: lvt gen resource posts title content:text --searchable When enabled: - Generates FTS5 virtual table for all string fields (excluding files/refs) - Creates INSERT/UPDATE/DELETE triggers to auto-sync the FTS index - Adds Search<Resource> SQL query using FTS5 MATCH with ranking - Handler uses FTS5 search instead of in-memory strings.Contains filtering Without --searchable, the existing in-memory search behavior is unchanged. Also adds SearchableFields() helper to ResourceData for template use. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review findings for M4 features FTS5 search: - Skip GetAll when search query present — go directly to FTS5 query - TotalCount reflects FTS result count, not unfiltered total - Validate --searchable requires at least one string field Scheduled tasks: - Validate schedule duration is a positive integer before emitting Go code - Fix PeriodicJobs() comment indentation (was misaligned with slice literal) Database console: - Remove duplicate cwd check in findDBPath - Handle os.Getwd() error properly - Stop walking at project boundary (go.mod) instead of filesystem root Code quality: - SearchableFields() composes on NonFileFields() instead of reimplementing - Remove stale test assertion about search comment Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Claude review — error on missing injection point, fix blank line - injectPeriodicJobsConfig returns error when River config target not found - Remove spurious blank line from Searchable template block when disabled Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — panic guard, schedule sanitization, FTS logging - Fix panic in GenTask when only flags passed (no task name) - Use %q for schedule string in fallback to prevent source injection - Log FTS search errors with log.Printf instead of silently swallowing - Add marker-based fallback for PeriodicJobs config injection - Document db alias in console help text Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use slog for FTS errors, validate schedule format upfront - FTS search errors logged with slog.Error (consistent with generated code) - Add slog import conditional on Searchable - Validate --schedule format upfront: reject unrecognized formats with clear error listing supported options - Document db alias in console help Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address final review — marker error, slog consistency, help docs - Return error when PeriodicJobs marker is missing (instead of silent no-op) - Sync single kit handler to use slog.Error for FTS (was log.Printf) - Add RunOnStart comment in generated periodic job scaffold - Document --searchable and --with-authz in resource help text - Remove redundant @every entries from validSchedules map Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address final review — error propagation, import injection, tests - Return error when time import injection target not found in worker.go - Propagate FTS search errors instead of swallowing (return fmt.Errorf) - Remove unused slog import conditional (error is propagated, not logged) - Add unit tests for scheduleToGo (7 cases), isPositiveInt (8 cases), and invalid/non-numeric schedule fallback (2 cases) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore deleted tests, fix RunOnStart default, remove redundant cases Critical: - Restore 6 deleted integration tests for GenerateQueue/GenerateJob/InjectJobWorker - Append new scheduleToGo/isPositiveInt unit tests alongside originals Issues: - Change RunOnStart default to false (daily tasks shouldn't fire on every restart) - Remove redundant @every hardcoded cases (generic parser handles them) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: reject @every 0m, improve marker error message - isValidEverySchedule rejects "0" duration (would generate broken code) - Clearer error when periodic job marker not found in worker.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c66f423 commit 481e9ba

26 files changed

+735
-33
lines changed

commands/console.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
)
9+
10+
// Console opens an interactive database shell for the current app.
11+
func Console(args []string) error {
12+
if ShowHelpIfRequested(args, printConsoleHelp) {
13+
return nil
14+
}
15+
16+
dbPath := findDBPath()
17+
if dbPath == "" {
18+
return fmt.Errorf("no database found. Are you in a LiveTemplate project directory?\nExpected: app.db or DATABASE_PATH environment variable")
19+
}
20+
21+
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
22+
return fmt.Errorf("database file not found: %s\nRun 'lvt migration up' to create it", dbPath)
23+
}
24+
25+
sqlite3Path, err := exec.LookPath("sqlite3")
26+
if err != nil {
27+
fmt.Printf("Database path: %s\n", dbPath)
28+
return fmt.Errorf("sqlite3 not found in PATH. Install it:\n macOS: brew install sqlite3\n Ubuntu: sudo apt install sqlite3\n Or open manually: sqlite3 %s", dbPath)
29+
}
30+
31+
fmt.Printf("Opening %s...\n", dbPath)
32+
fmt.Println("Type .tables to list tables, .schema to see schema, .quit to exit")
33+
fmt.Println()
34+
35+
cmd := exec.Command(sqlite3Path, "-header", "-column", dbPath)
36+
cmd.Stdin = os.Stdin
37+
cmd.Stdout = os.Stdout
38+
cmd.Stderr = os.Stderr
39+
return cmd.Run()
40+
}
41+
42+
// findDBPath locates the database file for the current project.
43+
func findDBPath() string {
44+
if path := os.Getenv("DATABASE_PATH"); path != "" {
45+
return path
46+
}
47+
48+
dir, err := os.Getwd()
49+
if err != nil {
50+
return ""
51+
}
52+
for {
53+
path := filepath.Join(dir, "app.db")
54+
if _, err := os.Stat(path); err == nil {
55+
return path
56+
}
57+
// Stop at project boundary (go.mod indicates project root)
58+
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
59+
break
60+
}
61+
parent := filepath.Dir(dir)
62+
if parent == dir {
63+
break
64+
}
65+
dir = parent
66+
}
67+
return ""
68+
}
69+
70+
func printConsoleHelp() {
71+
fmt.Println("Usage: lvt console (alias: lvt db)")
72+
fmt.Println()
73+
fmt.Println("Opens an interactive SQLite database shell for the current app.")
74+
fmt.Println()
75+
fmt.Println("The database is located by:")
76+
fmt.Println(" 1. DATABASE_PATH environment variable")
77+
fmt.Println(" 2. app.db in the current or parent directories")
78+
fmt.Println()
79+
fmt.Println("Useful SQLite commands:")
80+
fmt.Println(" .tables List all tables")
81+
fmt.Println(" .schema Show CREATE TABLE statements")
82+
fmt.Println(" .headers on Enable column headers")
83+
fmt.Println(" .mode csv Switch to CSV output")
84+
fmt.Println(" .quit Exit the console")
85+
fmt.Println()
86+
}

commands/gen.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ func Gen(args []string) error {
5555
return Authz(args[1:])
5656
case "api":
5757
return GenAPI(args[1:])
58+
case "task":
59+
return GenTask(args[1:])
5860
default:
59-
return fmt.Errorf("unknown subcommand: %s\n\nAvailable subcommands:\n resource Generate full CRUD resource with database\n view Generate view-only handler (no database)\n schema Generate database schema only\n auth Generate authentication system\n authz Generate role-based authorization\n api Generate JSON API endpoints\n stack Generate deployment stack configuration\n queue Set up background job processing (River)\n job Scaffold a new background job handler\n\nRun 'lvt gen' for interactive mode", subcommand)
61+
return fmt.Errorf("unknown subcommand: %s\n\nAvailable subcommands:\n resource Generate full CRUD resource with database\n view Generate view-only handler (no database)\n schema Generate database schema only\n auth Generate authentication system\n authz Generate role-based authorization\n api Generate JSON API endpoints\n stack Generate deployment stack configuration\n queue Set up background job processing (River)\n job Scaffold a new background job handler\n task Scaffold a new scheduled task\n\nRun 'lvt gen' for interactive mode", subcommand)
6062
}
6163
}
6264

@@ -119,6 +121,7 @@ func GenResource(args []string) error {
119121
skipValidation := false
120122
parentResource := ""
121123
withAuthz := false
124+
searchable := false
122125
var filteredArgs []string
123126
for i := 0; i < len(args); i++ {
124127
if args[i] == "--pagination" && i+1 < len(args) {
@@ -139,6 +142,8 @@ func GenResource(args []string) error {
139142
i++ // skip next arg
140143
} else if args[i] == "--with-authz" {
141144
withAuthz = true
145+
} else if args[i] == "--searchable" {
146+
searchable = true
142147
} else {
143148
filteredArgs = append(filteredArgs, args[i])
144149
}
@@ -245,7 +250,7 @@ func GenResource(args []string) error {
245250
fmt.Println()
246251

247252
styles := projectConfig.Styles
248-
if err := generator.GenerateResource(basePath, moduleName, resourceName, fields, kit, cssFramework, styles, paginationMode, pageSize, editMode, parentResource, withAuthz); err != nil {
253+
if err := generator.GenerateResource(basePath, moduleName, resourceName, fields, kit, cssFramework, styles, paginationMode, pageSize, editMode, parentResource, withAuthz, searchable); err != nil {
249254
capture.RecordError(telemetry.GenerationError{Phase: "generation", Message: err.Error()})
250255
capture.AttributeComponentErrors() // attribute errors on failure path
251256
capture.Complete(false, "")

commands/help.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ func printGenResourceHelp() {
7575
fmt.Println(" --pagination <mode> Pagination: infinite, load-more, prev-next, numbers")
7676
fmt.Println(" --page-size <n> Items per page (default: 20)")
7777
fmt.Println(" --edit-mode <mode> Edit mode: modal, page")
78+
fmt.Println(" --with-authz Add ownership tracking and permission checks")
79+
fmt.Println(" --searchable Enable FTS5 full-text search on string fields")
7880
fmt.Println(" --skip-validation Skip post-generation validation checks")
7981
fmt.Println()
8082
fmt.Println("Examples:")

commands/task.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
8+
"github.com/livetemplate/lvt/internal/config"
9+
"github.com/livetemplate/lvt/internal/generator"
10+
)
11+
12+
// GenTask scaffolds a new scheduled task.
13+
func GenTask(args []string) error {
14+
if ShowHelpIfRequested(args, printGenTaskHelp) {
15+
return nil
16+
}
17+
18+
if len(args) < 1 {
19+
return fmt.Errorf("task name required\n\nUsage: lvt gen task <name> --schedule <interval>\n\nExamples:\n lvt gen task cleanup --schedule @hourly\n lvt gen task daily_report --schedule @daily")
20+
}
21+
22+
// Parse flags
23+
schedule := "@hourly" // default
24+
var filteredArgs []string
25+
for i := 0; i < len(args); i++ {
26+
if (args[i] == "--schedule" || args[i] == "-s") && i+1 < len(args) {
27+
schedule = args[i+1]
28+
i++
29+
} else {
30+
filteredArgs = append(filteredArgs, args[i])
31+
}
32+
}
33+
34+
if len(filteredArgs) < 1 {
35+
return fmt.Errorf("task name required\n\nUsage: lvt gen task <name> --schedule <interval>")
36+
}
37+
taskName := strings.ToLower(strings.TrimSpace(filteredArgs[0]))
38+
if taskName == "" {
39+
return fmt.Errorf("task name cannot be empty")
40+
}
41+
42+
if err := ValidatePositionalArg(taskName, "task name"); err != nil {
43+
return err
44+
}
45+
46+
validSchedules := map[string]bool{
47+
"@hourly": true, "@daily": true, "@weekly": true,
48+
}
49+
if !validSchedules[schedule] && !isValidEverySchedule(schedule) {
50+
return fmt.Errorf("unsupported schedule %q\n\nSupported formats:\n @hourly, @daily, @weekly\n @every Nm (e.g., @every 5m, @every 2h, @every 30s)", schedule)
51+
}
52+
53+
cwd, err := os.Getwd()
54+
if err != nil {
55+
return fmt.Errorf("failed to get working directory: %w", err)
56+
}
57+
58+
projectConfig, err := config.LoadProjectConfig(cwd)
59+
if err != nil {
60+
return fmt.Errorf("failed to load project config: %w", err)
61+
}
62+
63+
moduleName := projectConfig.Module
64+
if moduleName == "" {
65+
return fmt.Errorf("could not determine module name from project config")
66+
}
67+
68+
if err := generator.GenerateTask(cwd, moduleName, taskName, schedule); err != nil {
69+
return err
70+
}
71+
72+
fmt.Println()
73+
fmt.Printf("✅ Scheduled task '%s' created!\n", taskName)
74+
fmt.Println()
75+
fmt.Printf("Schedule: %s\n", schedule)
76+
fmt.Println()
77+
fmt.Println("Generated files:")
78+
fmt.Printf(" app/jobs/%s.go Task handler\n", taskName)
79+
fmt.Println()
80+
fmt.Println("Next steps:")
81+
fmt.Printf(" 1. Edit app/jobs/%s.go to implement your task logic\n", taskName)
82+
fmt.Println(" 2. The task will run automatically when the app starts")
83+
fmt.Println()
84+
85+
return nil
86+
}
87+
88+
func printGenTaskHelp() {
89+
fmt.Println("Usage: lvt gen task <name> [--schedule <interval>]")
90+
fmt.Println()
91+
fmt.Println("Scaffold a new scheduled/recurring task.")
92+
fmt.Println("Tasks run automatically on a schedule using the River job queue.")
93+
fmt.Println()
94+
fmt.Println("Options:")
95+
fmt.Println(" --schedule, -s Schedule interval (default: @hourly)")
96+
fmt.Println()
97+
fmt.Println("Schedule shortcuts:")
98+
fmt.Println(" @hourly Run every hour")
99+
fmt.Println(" @daily Run every day")
100+
fmt.Println(" @weekly Run every week")
101+
fmt.Println(" @every 5m Run every 5 minutes")
102+
fmt.Println(" @every 30m Run every 30 minutes")
103+
fmt.Println(" @every 2h Run every 2 hours")
104+
fmt.Println()
105+
fmt.Println("Examples:")
106+
fmt.Println(" lvt gen task cleanup_sessions --schedule @hourly")
107+
fmt.Println(" lvt gen task daily_report --schedule @daily")
108+
fmt.Println(" lvt gen task sync_data --schedule \"@every 5m\"")
109+
fmt.Println()
110+
fmt.Println("Prerequisites:")
111+
fmt.Println(" Run 'lvt gen queue' first to set up the job infrastructure.")
112+
}
113+
114+
func isValidEverySchedule(s string) bool {
115+
if !strings.HasPrefix(s, "@every ") {
116+
return false
117+
}
118+
parts := strings.Fields(s)
119+
if len(parts) < 2 {
120+
return false
121+
}
122+
d := parts[1]
123+
if len(d) < 2 {
124+
return false
125+
}
126+
suffix := d[len(d)-1]
127+
if suffix != 'm' && suffix != 'h' && suffix != 's' {
128+
return false
129+
}
130+
n := d[:len(d)-1]
131+
for _, c := range n {
132+
if c < '0' || c > '9' {
133+
return false
134+
}
135+
}
136+
if len(n) == 0 || n == "0" {
137+
return false
138+
}
139+
return true
140+
}

golden_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func runHandlerGoldenTest(t *testing.T, resourceName string, fields []parser.Fie
2121
t.Fatalf("Failed to create database directory: %v", err)
2222
}
2323

24-
if err := generator.GenerateResource(tmpDir, "testmodule", resourceName, fields, "multi", "tailwind", "tailwind", "infinite", 20, "modal", "", authz); err != nil {
24+
if err := generator.GenerateResource(tmpDir, "testmodule", resourceName, fields, "multi", "tailwind", "tailwind", "infinite", 20, "modal", "", authz, false); err != nil {
2525
t.Fatalf("Failed to generate resource: %v", err)
2626
}
2727

@@ -101,7 +101,7 @@ func TestAuthzResourceHandlerGolden(t *testing.T) {
101101
}
102102
runHandlerGoldenTest(t, "Post", fields,
103103
"testdata/golden/resource_handler_authz.go.golden",
104-
"app/post/post.go", true)
104+
"app/post/post.go", true, false)
105105
}
106106

107107
// TestResourceHandlerUnstyledImport verifies that styles="unstyled" generates the unstyled import
@@ -117,7 +117,7 @@ func TestResourceHandlerUnstyledImport(t *testing.T) {
117117
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT", Metadata: parser.GetFieldMetadata("string")},
118118
}
119119

120-
if err := generator.GenerateResource(tmpDir, "testmodule", "Item", fields, "multi", "tailwind", "unstyled", "infinite", 20, "modal", "", false); err != nil {
120+
if err := generator.GenerateResource(tmpDir, "testmodule", "Item", fields, "multi", "tailwind", "unstyled", "infinite", 20, "modal", "", false, false); err != nil {
121121
t.Fatalf("Failed to generate resource: %v", err)
122122
}
123123

@@ -149,7 +149,7 @@ func TestResourceHandlerInvalidStyles(t *testing.T) {
149149
{Name: "name", Type: "string", GoType: "string", SQLType: "TEXT", Metadata: parser.GetFieldMetadata("string")},
150150
}
151151

152-
err := generator.GenerateResource(tmpDir, "testmodule", "Item", fields, "multi", "tailwind", "bootstrap", "infinite", 20, "modal", "", false)
152+
err := generator.GenerateResource(tmpDir, "testmodule", "Item", fields, "multi", "tailwind", "bootstrap", "infinite", 20, "modal", "", false, false)
153153
if err == nil {
154154
t.Fatal("Expected error for invalid styles adapter, got nil")
155155
}
@@ -215,7 +215,7 @@ func TestResourceTemplateGolden(t *testing.T) {
215215
{Name: "published", Type: "bool", GoType: "bool", SQLType: "BOOLEAN", Metadata: parser.GetFieldMetadata("bool")},
216216
}
217217

218-
if err := generator.GenerateResource(tmpDir, "testmodule", "Post", fields, "multi", "tailwind", "tailwind", "prev-next", 10, "modal", "", false); err != nil {
218+
if err := generator.GenerateResource(tmpDir, "testmodule", "Post", fields, "multi", "tailwind", "tailwind", "prev-next", 10, "modal", "", false, false); err != nil {
219219
t.Fatalf("Failed to generate resource: %v", err)
220220
}
221221

0 commit comments

Comments
 (0)