← 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
M internal/generator/generator.goM internal/generator/schema_builder.goM internal/generator/templates/search.go.tmplM internal/generator/templates/store.go.tmplM internal/generator/templates/sync.go.tmpl
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 →