[object Object]

← back to Cli Printing Press

feat(generator): wire BuildSchema to store/sync/search templates

eb59816ba04347f0ccee0f0851b3a9cd17942419 · 2026-03-26 12:14:22 -0700 · Matt Van Horn

Connect the three disconnected generator subsystems:
- schema_builder.go BuildSchema() output now passed to store.go.tmpl
- store.go.tmpl generates domain-specific CREATE TABLE with proper
  columns, indexes, FTS5, and UpsertX()/SearchX() methods
- sync.go.tmpl calls domain-specific UpsertX() via switch dispatch
  instead of generic Upsert()
- search.go.tmpl dispatches to SearchX() for FTS-backed tables
- defaultSyncResources() returns actual resource list (not empty)
- Deduplicate tables in BuildSchema to prevent sub-resource name collisions

Added template helpers: pascal, goStoreType, camelToJSON, columnNames,
columnPlaceholders, updateSet, join.

Regenerated discord-cli-v2: DataPipelineIntegrity 4/10 -> 10/10.
60 domain-specific CREATE TABLE statements. UpsertGuilds, UpsertChannels,
UpsertMessages, UpsertMembers methods generated. SearchGuilds for FTS5.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit eb59816ba04347f0ccee0f0851b3a9cd17942419
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Thu Mar 26 12:14:22 2026 -0700

    feat(generator): wire BuildSchema to store/sync/search templates
    
    Connect the three disconnected generator subsystems:
    - schema_builder.go BuildSchema() output now passed to store.go.tmpl
    - store.go.tmpl generates domain-specific CREATE TABLE with proper
      columns, indexes, FTS5, and UpsertX()/SearchX() methods
    - sync.go.tmpl calls domain-specific UpsertX() via switch dispatch
      instead of generic Upsert()
    - search.go.tmpl dispatches to SearchX() for FTS-backed tables
    - defaultSyncResources() returns actual resource list (not empty)
    - Deduplicate tables in BuildSchema to prevent sub-resource name collisions
    
    Added template helpers: pascal, goStoreType, camelToJSON, columnNames,
    columnPlaceholders, updateSet, join.
    
    Regenerated discord-cli-v2: DataPipelineIntegrity 4/10 -> 10/10.
    60 domain-specific CREATE TABLE statements. UpsertGuilds, UpsertChannels,
    UpsertMessages, UpsertMembers methods generated. SearchGuilds for FTS5.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go             | 140 ++++++++++++++++++----
 internal/generator/schema_builder.go        |  11 ++
 internal/generator/templates/search.go.tmpl |  12 +-
 internal/generator/templates/store.go.tmpl  | 179 +++++++++++++++++++++++++---
 internal/generator/templates/sync.go.tmpl   |  48 ++++++--
 5 files changed, 346 insertions(+), 44 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 3ee87de1..d44c094b 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -33,25 +33,32 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 	}
 	g := &Generator{Spec: s, OutputDir: outputDir}
 	g.funcs = template.FuncMap{
-		"title":             strings.Title,
-		"lower":             strings.ToLower,
-		"upper":             strings.ToUpper,
-		"camel":             toCamel,
-		"snake":             toSnake,
-		"goType":            goType,
-		"cobraFlagFunc":     cobraFlagFunc,
-		"defaultVal":        defaultVal,
-		"zeroVal":           zeroVal,
-		"positionalArgs":    positionalArgs,
-		"configTag":         configTag,
-		"envVarField":       envVarField,
-		"envVarPlaceholder": envVarPlaceholder,
-		"add":               func(a, b int) int { return a + b },
-		"oneline":           oneline,
-		"flagName":          flagName,
-		"safeTypeName":      safeTypeName,
-		"exampleLine":       g.exampleLine,
-		"currentYear":       func() string { return strconv.Itoa(time.Now().Year()) },
+		"title":              strings.Title,
+		"lower":              strings.ToLower,
+		"upper":              strings.ToUpper,
+		"join":               strings.Join,
+		"camel":              toCamel,
+		"snake":              toSnake,
+		"pascal":             toPascal,
+		"goType":             goType,
+		"goStoreType":        goStoreType,
+		"cobraFlagFunc":      cobraFlagFunc,
+		"defaultVal":         defaultVal,
+		"zeroVal":            zeroVal,
+		"positionalArgs":     positionalArgs,
+		"configTag":          configTag,
+		"camelToJSON":        camelToJSON,
+		"columnNames":        columnNames,
+		"columnPlaceholders": columnPlaceholders,
+		"updateSet":          updateSet,
+		"envVarField":        envVarField,
+		"envVarPlaceholder":  envVarPlaceholder,
+		"add":                func(a, b int) int { return a + b },
+		"oneline":            oneline,
+		"flagName":           flagName,
+		"safeTypeName":       safeTypeName,
+		"exampleLine":        g.exampleLine,
+		"currentYear":        func() string { return strconv.Itoa(time.Now().Year()) },
 	}
 	return g
 }
@@ -205,6 +212,7 @@ func (g *Generator) Generate() error {
 	if g.profile == nil {
 		g.profile = profiler.Profile(g.Spec)
 	}
+	schema := BuildSchema(g.Spec)
 
 	// Create store directory if needed
 	if g.VisionSet.Store {
@@ -215,10 +223,12 @@ func (g *Generator) Generate() error {
 			*spec.APISpec
 			SyncableResources []string
 			SearchableFields  map[string][]string
+			Tables            []TableDef
 		}{
 			APISpec:           g.Spec,
 			SyncableResources: g.profile.SyncableResources,
 			SearchableFields:  g.profile.SearchableFields,
+			Tables:            schema,
 		}
 		if err := g.renderTemplate("store.go.tmpl", filepath.Join("internal", "store", "store.go"), storeData); err != nil {
 			return fmt.Errorf("rendering store: %w", err)
@@ -235,6 +245,18 @@ func (g *Generator) Generate() error {
 		"analytics.go.tmpl": filepath.Join("internal", "cli", "analytics.go"),
 	}
 
+	visionData := struct {
+		*spec.APISpec
+		SyncableResources []string
+		SearchableFields  map[string][]string
+		Tables            []TableDef
+	}{
+		APISpec:           g.Spec,
+		SyncableResources: g.profile.SyncableResources,
+		SearchableFields:  g.profile.SearchableFields,
+		Tables:            schema,
+	}
+
 	for _, tmplName := range g.VisionSet.TemplateNames() {
 		if tmplName == "store.go.tmpl" {
 			continue // already rendered above
@@ -243,7 +265,11 @@ func (g *Generator) Generate() error {
 		if !ok {
 			continue
 		}
-		if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
+		var tmplData any = g.Spec
+		if tmplName == "sync.go.tmpl" || tmplName == "search.go.tmpl" {
+			tmplData = visionData
+		}
+		if err := g.renderTemplate(tmplName, outPath, tmplData); err != nil {
 			return fmt.Errorf("rendering vision %s: %w", tmplName, err)
 		}
 	}
@@ -353,6 +379,20 @@ func toSnake(s string) string {
 	return result.String()
 }
 
+func toPascal(s string) string {
+	parts := strings.FieldsFunc(s, func(r rune) bool {
+		return r == '_' || r == '-' || !unicode.IsLetter(r) && !unicode.IsDigit(r)
+	})
+	for i, part := range parts {
+		if part == "" {
+			continue
+		}
+		lower := strings.ToLower(part)
+		parts[i] = strings.ToUpper(lower[:1]) + lower[1:]
+	}
+	return strings.Join(parts, "")
+}
+
 func goType(t string) string {
 	switch t {
 	case "string":
@@ -368,6 +408,66 @@ func goType(t string) string {
 	}
 }
 
+func goStoreType(sqlType string) string {
+	upper := strings.ToUpper(sqlType)
+	switch {
+	case strings.HasPrefix(upper, "INTEGER"):
+		return "int"
+	case strings.HasPrefix(upper, "REAL"):
+		return "float64"
+	case strings.HasPrefix(upper, "JSON"):
+		return "json.RawMessage"
+	case strings.HasPrefix(upper, "DATETIME"):
+		return "string"
+	default:
+		return "string"
+	}
+}
+
+func camelToJSON(s string) string {
+	parts := strings.Split(strings.ToLower(s), "_")
+	if len(parts) == 0 {
+		return s
+	}
+	for i := 1; i < len(parts); i++ {
+		if parts[i] == "" {
+			continue
+		}
+		parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
+	}
+	return strings.Join(parts, "")
+}
+
+func columnNames(cols []ColumnDef) string {
+	names := make([]string, 0, len(cols))
+	for _, col := range cols {
+		names = append(names, col.Name)
+	}
+	return strings.Join(names, ", ")
+}
+
+func columnPlaceholders(cols []ColumnDef) string {
+	if len(cols) == 0 {
+		return ""
+	}
+	placeholders := make([]string, len(cols))
+	for i := range cols {
+		placeholders[i] = "?"
+	}
+	return strings.Join(placeholders, ", ")
+}
+
+func updateSet(cols []ColumnDef) string {
+	var updates []string
+	for _, col := range cols {
+		if col.PrimaryKey {
+			continue
+		}
+		updates = append(updates, fmt.Sprintf("%s = excluded.%s", col.Name, col.Name))
+	}
+	return strings.Join(updates, ", ")
+}
+
 func cobraFlagFunc(t string) string {
 	switch t {
 	case "string":
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 51b63f07..7c6bedb2 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -93,6 +93,17 @@ func BuildSchema(s *spec.APISpec) []TableDef {
 		}
 	}
 
+	// Deduplicate tables by name (sub-resources from different parents can collide)
+	seen := make(map[string]bool)
+	var deduped []TableDef
+	for _, t := range tables {
+		if !seen[t.Name] {
+			seen[t.Name] = true
+			deduped = append(deduped, t)
+		}
+	}
+	tables = deduped
+
 	tables = append(tables, TableDef{
 		Name: "sync_state",
 		Columns: []ColumnDef{
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index d03ace94..2726b822 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -47,7 +47,17 @@ Data must be synced first with the sync command. Searches are instant
 			}
 			defer db.Close()
 
-			results, err := db.Search(query, limit)
+			var results []json.RawMessage
+			switch resourceType {
+{{- range .Tables}}
+{{- if .FTS5}}
+			case "{{.Name}}":
+				results, err = db.Search{{pascal .Name}}(query, limit)
+{{- end}}
+{{- end}}
+			default:
+				results, err = db.Search(query, limit)
+			}
 			if err != nil {
 				return fmt.Errorf("search failed: %w", err)
 			}
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index f288cc39..da98aadf 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -12,6 +12,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	_ "modernc.org/sqlite"
@@ -67,12 +68,42 @@ func (s *Store) migrate() error {
 		`CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
 			id, resource_type, content, tokenize='porter unicode61'
 		)`,
-{{- range .SyncableResources}}
-		`CREATE TABLE IF NOT EXISTS {{snake .}} (
-			id TEXT PRIMARY KEY,
-			data JSON NOT NULL,
-			synced_at DATETIME DEFAULT CURRENT_TIMESTAMP
+{{- range .Tables}}
+{{- if ne .Name "sync_state"}}
+		`CREATE TABLE IF NOT EXISTS {{.Name}} (
+{{- range $i, $col := .Columns}}
+{{- if $i}},{{end}}
+			{{$col.Name}} {{$col.Type}}{{if $col.PrimaryKey}} PRIMARY KEY{{end}}{{if $col.NotNull}} NOT NULL{{end}}
+{{- end}}
 		)`,
+{{- range .Indexes}}
+		`CREATE {{if .Unique}}UNIQUE {{end}}INDEX IF NOT EXISTS {{.Name}} ON {{.TableName}}({{.Columns}})`,
+{{- end}}
+{{- if .FTS5}}
+		`CREATE VIRTUAL TABLE IF NOT EXISTS {{.Name}}_fts USING fts5(
+{{- range $i, $field := .FTS5Fields}}
+{{- if $i}},{{end}}
+			{{$field}}
+{{- end}},
+			content='{{.Name}}',
+			content_rowid='rowid'
+		)`,
+		`CREATE TRIGGER IF NOT EXISTS {{.Name}}_ai AFTER INSERT ON {{.Name}} BEGIN
+			INSERT INTO {{.Name}}_fts(rowid, {{join .FTS5Fields ", "}})
+			VALUES (new.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}new.{{$field}}{{- end}});
+		END`,
+		`CREATE TRIGGER IF NOT EXISTS {{.Name}}_ad AFTER DELETE ON {{.Name}} BEGIN
+			INSERT INTO {{.Name}}_fts({{.Name}}_fts, rowid, {{join .FTS5Fields ", "}})
+			VALUES ('delete', old.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}old.{{$field}}{{- end}});
+		END`,
+		`CREATE TRIGGER IF NOT EXISTS {{.Name}}_au AFTER UPDATE ON {{.Name}} BEGIN
+			INSERT INTO {{.Name}}_fts({{.Name}}_fts, rowid, {{join .FTS5Fields ", "}})
+			VALUES ('delete', old.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}old.{{$field}}{{- end}});
+			INSERT INTO {{.Name}}_fts(rowid, {{join .FTS5Fields ", "}})
+			VALUES (new.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}new.{{$field}}{{- end}});
+		END`,
+{{- end}}
+{{- end}}
 {{- end}}
 	}
 
@@ -84,14 +115,8 @@ func (s *Store) migrate() error {
 	return nil
 }
 
-func (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {
-	tx, err := s.db.Begin()
-	if err != nil {
-		return err
-	}
-	defer tx.Rollback()
-
-	_, err = tx.Exec(
+func (s *Store) upsertGenericResourceTx(tx *sql.Tx, resourceType, id string, data json.RawMessage) error {
+	_, err := tx.Exec(
 		`INSERT INTO resources (id, resource_type, data, synced_at, updated_at)
 		 VALUES (?, ?, ?, ?, ?)
 		 ON CONFLICT(id) DO UPDATE SET data = excluded.data, synced_at = excluded.synced_at, updated_at = excluded.updated_at`,
@@ -101,10 +126,14 @@ func (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {
 		return err
 	}
 
+	_, err = tx.Exec(`DELETE FROM resources_fts WHERE id = ?`, id)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: FTS index cleanup failed: %v\n", err)
+	}
+
 	_, err = tx.Exec(
 		`INSERT INTO resources_fts (id, resource_type, content)
-		 VALUES (?, ?, ?)
-		 ON CONFLICT DO NOTHING`,
+		 VALUES (?, ?, ?)`,
 		id, resourceType, string(data),
 	)
 	if err != nil {
@@ -112,6 +141,20 @@ func (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {
 		fmt.Fprintf(os.Stderr, "warning: FTS index update failed: %v\n", err)
 	}
 
+	return nil
+}
+
+func (s *Store) Upsert(resourceType, id string, data json.RawMessage) error {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	if err := s.upsertGenericResourceTx(tx, resourceType, id, data); err != nil {
+		return err
+	}
+
 	return tx.Commit()
 }
 
@@ -182,6 +225,112 @@ func (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {
 	return results, rows.Err()
 }
 
+func extractObjectID(obj map[string]any) string {
+	for _, key := range []string{"id", "ID", "uuid", "slug", "name"} {
+		if v, ok := obj[key]; ok {
+			return fmt.Sprintf("%v", v)
+		}
+	}
+	return ""
+}
+
+func lookupFieldValue(obj map[string]any, snakeKey string) any {
+	if v, ok := obj[snakeKey]; ok {
+		return v
+	}
+	parts := strings.Split(snakeKey, "_")
+	for i := 1; i < len(parts); i++ {
+		if parts[i] == "" {
+			continue
+		}
+		parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
+	}
+	if v, ok := obj[strings.Join(parts, "")]; ok {
+		return v
+	}
+	return nil
+}
+
+{{- range .Tables}}
+{{- if and (gt (len .Columns) 3) (ne .Name "sync_state")}}
+// Upsert{{pascal .Name}} inserts or updates a {{.Name}} record with domain-specific columns.
+func (s *Store) Upsert{{pascal .Name}}(data json.RawMessage) error {
+	var obj map[string]any
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return fmt.Errorf("unmarshaling {{.Name}}: %w", err)
+	}
+
+	id := extractObjectID(obj)
+	if id == "" {
+		return fmt.Errorf("missing id for {{.Name}}")
+	}
+
+	tx, err := s.db.Begin()
+	if err != nil {
+		return err
+	}
+	defer tx.Rollback()
+
+	if err := s.upsertGenericResourceTx(tx, "{{.Name}}", id, data); err != nil {
+		return err
+	}
+
+	_, err = tx.Exec(
+		`INSERT INTO {{.Name}} ({{columnNames .Columns}})
+		 VALUES ({{columnPlaceholders .Columns}})
+		 ON CONFLICT(id) DO UPDATE SET {{updateSet .Columns}}`,
+		id,
+		string(data),
+		time.Now(),
+{{- range .Columns}}
+{{- if and (ne .Name "id") (ne .Name "data") (ne .Name "synced_at")}}
+		lookupFieldValue(obj, "{{.Name}}"),
+{{- end}}
+{{- end}}
+	)
+	if err != nil {
+		return err
+	}
+
+	return tx.Commit()
+}
+
+{{- end}}
+{{- end}}
+
+{{- range .Tables}}
+{{- if .FTS5}}
+// Search{{pascal .Name}} searches the {{.Name}}_fts index with optional filters.
+func (s *Store) Search{{pascal .Name}}(query string, limit int) ([]json.RawMessage, error) {
+	if limit <= 0 {
+		limit = 50
+	}
+	rows, err := s.db.Query(
+		`SELECT t.data FROM {{.Name}} t
+		 JOIN {{.Name}}_fts ON {{.Name}}_fts.rowid = t.rowid
+		 WHERE {{.Name}}_fts MATCH ?
+		 ORDER BY rank LIMIT ?`,
+		query, limit,
+	)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var results []json.RawMessage
+	for rows.Next() {
+		var data string
+		if err := rows.Scan(&data); err != nil {
+			return nil, err
+		}
+		results = append(results, json.RawMessage(data))
+	}
+	return results, rows.Err()
+}
+
+{{- end}}
+{{- end}}
+
 func (s *Store) SaveSyncState(resourceType, cursor string, count int) error {
 	_, err := s.db.Exec(
 		`INSERT INTO sync_state (resource_type, last_cursor, last_synced_at, total_count)
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index ddaf8768..0749adce 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -69,9 +69,26 @@ Once synced, use the 'search' command for instant full-text search.`,
 				var items []json.RawMessage
 				if err := json.Unmarshal(data, &items); err != nil {
 					// Single object response - store as-is
-					if err := db.Upsert(resource, resource, data); err != nil {
-						fmt.Fprintf(os.Stderr, "warning: failed to store %s: %v\n", resource, err)
-					} else {
+					stored := false
+					switch resource {
+{{- range .Tables}}
+{{- if and (gt (len .Columns) 3) (ne .Name "sync_state")}}
+					case "{{.Name}}":
+						if err := db.Upsert{{pascal .Name}}(data); err != nil {
+							fmt.Fprintf(os.Stderr, "warning: failed to store %s: %v\n", resource, err)
+						} else {
+							stored = true
+						}
+{{- end}}
+{{- end}}
+					default:
+						if err := db.Upsert(resource, resource, data); err != nil {
+							fmt.Fprintf(os.Stderr, "warning: failed to store %s: %v\n", resource, err)
+						} else {
+							stored = true
+						}
+					}
+					if stored {
 						totalSynced++
 					}
 					continue
@@ -87,9 +104,21 @@ Once synced, use the 'search' command for instant full-text search.`,
 					if id == "" {
 						id = fmt.Sprintf("%s-%d", resource, count)
 					}
-					if err := db.Upsert(resource, id, item); err != nil {
-						fmt.Fprintf(os.Stderr, "warning: failed to store %s/%s: %v\n", resource, id, err)
-						continue
+					switch resource {
+{{- range .Tables}}
+{{- if and (gt (len .Columns) 3) (ne .Name "sync_state")}}
+					case "{{.Name}}":
+						if err := db.Upsert{{pascal .Name}}(item); err != nil {
+							fmt.Fprintf(os.Stderr, "warning: failed to store %s/%s: %v\n", resource, id, err)
+							continue
+						}
+{{- end}}
+{{- end}}
+					default:
+						if err := db.Upsert(resource, id, item); err != nil {
+							fmt.Fprintf(os.Stderr, "warning: failed to store %s/%s: %v\n", resource, id, err)
+							continue
+						}
 					}
 					count++
 				}
@@ -115,8 +144,11 @@ Once synced, use the 'search' command for instant full-text search.`,
 }
 
 func defaultSyncResources() []string {
-	// Override this in the generated CLI to list the API's top-level list endpoints
-	return []string{}
+	return []string{
+{{- range .SyncableResources}}
+		"{{.}}",
+{{- end}}
+	}
 }
 
 func extractID(obj map[string]any) string {

← bba34c42 feat(scorecard): add Tier 2 domain correctness dimensions  ·  back to Cli Printing Press  ·  fix(generator): auth format, module path, and example values 0063ee76 →