[object Object]

← back to Cli Printing Press

fix(cli): dispatch typed-table upserts on spec resource key, not snake table name (#1071)

705bad2df0c1081ddb5e42a7aa949c5767ae52b0 · 2026-05-11 10:40:49 -0700 · Trevin Chow

The typed-handler switch in store.go.tmpl (UpsertBatch), sync.go.tmpl
(upsertSingleObject), and search.go.tmpl (--type filter) emitted case
strings derived from TableDef.Name, which is snake_cased for SQL DDL.
The runtime resource variable is the un-converted spec resource key,
which is kebab-case for kebab-keyed specs (elevenlabs, etc.). For every
multi-word resource the case never matched: paginated syncs left typed
tables empty, single-object responses fell through to the generic
upsert, and search --type <kebab-resource> returned no results.

Adds Resource to TableDef capturing the original spec key, populated at
the three TableDef construction sites (top-level loop, sync_state
synthetic, sub-resource builder). The three dispatch templates and the
singular Upsert<X> helper's call to upsertGenericResourceTx now use
{{.Resource}} so the generic resources table stores the same identifier
across single-object and batch write paths.

Updates store_upsert_batch_test.go.tmpl to use {{.Resource}} on the
UpsertBatch call and resource_type filter, keeping {{.Name}} only where
it identifies the typed SQL table.

Refs #1064

Files touched

Diff

commit 705bad2df0c1081ddb5e42a7aa949c5767ae52b0
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 10:40:49 2026 -0700

    fix(cli): dispatch typed-table upserts on spec resource key, not snake table name (#1071)
    
    The typed-handler switch in store.go.tmpl (UpsertBatch), sync.go.tmpl
    (upsertSingleObject), and search.go.tmpl (--type filter) emitted case
    strings derived from TableDef.Name, which is snake_cased for SQL DDL.
    The runtime resource variable is the un-converted spec resource key,
    which is kebab-case for kebab-keyed specs (elevenlabs, etc.). For every
    multi-word resource the case never matched: paginated syncs left typed
    tables empty, single-object responses fell through to the generic
    upsert, and search --type <kebab-resource> returned no results.
    
    Adds Resource to TableDef capturing the original spec key, populated at
    the three TableDef construction sites (top-level loop, sync_state
    synthetic, sub-resource builder). The three dispatch templates and the
    singular Upsert<X> helper's call to upsertGenericResourceTx now use
    {{.Resource}} so the generic resources table stores the same identifier
    across single-object and batch write paths.
    
    Updates store_upsert_batch_test.go.tmpl to use {{.Resource}} on the
    UpsertBatch call and resource_type filter, keeping {{.Name}} only where
    it identifies the typed SQL table.
    
    Refs #1064
---
 internal/generator/generator_test.go               | 102 +++++++++++++++++++++
 internal/generator/schema_builder.go               |  22 +++--
 internal/generator/templates/search.go.tmpl        |   2 +-
 internal/generator/templates/store.go.tmpl         |   4 +-
 .../templates/store_upsert_batch_test.go.tmpl      |   6 +-
 internal/generator/templates/sync.go.tmpl          |   2 +-
 6 files changed, 125 insertions(+), 13 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index a60d65f7..727ae218 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2598,6 +2598,108 @@ func TestGenerateStoreUpsertBatchDispatchesToTypedTable(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/store")
 }
 
+// TestUpsertDispatchPreservesMultiWordResourceCasing is the regression test
+// for issue #1064: dispatch case strings must use the spec resource key,
+// not the snake-cased table name, or kebab multi-word resources silently
+// skip the typed upsert and FTS search paths.
+func TestUpsertDispatchPreservesMultiWordResourceCasing(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "media",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+		Config:  spec.ConfigSpec{Format: "toml", Path: "~/.config/media-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"audio-isolation": {
+				Description: "Isolated audio tracks",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/v1/audio-isolation",
+						Description: "List isolation jobs",
+						Response:    spec.ResponseDef{Type: "array", Item: "AudioIsolation"},
+					},
+				},
+			},
+		},
+		Types: map[string]spec.TypeDef{
+			"AudioIsolation": {
+				Fields: []spec.TypeField{
+					{Name: "id", Type: "string"},
+					{Name: "name", Type: "string"},
+					// Two FTS-eligible text fields force the schema builder
+					// to emit FTS5, which in turn emits the search dispatch
+					// switch that #1064 also affects.
+					{Name: "description", Type: "string"},
+					{Name: "status", Type: "string"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	gen.VisionSet = VisionTemplateSet{Store: true, Sync: true, Search: true}
+	require.NoError(t, gen.Generate())
+
+	storeSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "store", "store.go"))
+	require.NoError(t, err)
+	store := string(storeSrc)
+
+	syncSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+	sync := string(syncSrc)
+
+	searchSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "search.go"))
+	require.NoError(t, err)
+	search := string(searchSrc)
+
+	// The typed helper name keeps its snake-derived PascalCase shape — the
+	// fix only changes the case-string discriminant, not the Go identifier.
+	assert.Contains(t, store, "func (s *Store) upsertAudioIsolationTx(",
+		"typed Tx helper should still be PascalCase-named from snake schema name")
+	assert.Contains(t, store, "func (s *Store) UpsertAudioIsolation(",
+		"public typed upsert should still be PascalCase-named from snake schema name")
+	assert.Contains(t, store, "func (s *Store) SearchAudioIsolation(",
+		"FTS5 search helper should still be PascalCase-named from snake schema name")
+
+	// UpsertBatch must dispatch on the runtime resource form (kebab) to
+	// match defaultSyncResources/syncResourcePath/resourceIDFieldOverrides.
+	assert.Regexp(t,
+		`(?s)func \(s \*Store\) UpsertBatch\(.*case "audio-isolation":\s+if err := s\.upsertAudioIsolationTx\(`,
+		store,
+		"UpsertBatch must dispatch on \"audio-isolation\" — the snake case string never matches the kebab runtime resource (issue #1064)")
+
+	// upsertSingleObject must dispatch the same way.
+	assert.Regexp(t,
+		`(?s)func upsertSingleObject\(.*case "audio-isolation":\s+return db\.UpsertAudioIsolation\(data\)`,
+		sync,
+		"upsertSingleObject must dispatch on \"audio-isolation\" so single-object responses reach the typed table (issue #1064)")
+
+	// search.go's --type filter has the same shape — the user passes the
+	// kebab name (matching what `sync` advertises), so the case must too.
+	assert.Regexp(t,
+		`(?s)switch resourceType \{.*case "audio-isolation":\s+results, err = db\.SearchAudioIsolation\(query, limit\)`,
+		search,
+		"search dispatch must accept \"audio-isolation\" so --type <kebab-resource> reaches the FTS index (issue #1064)")
+
+	// And the old snake-case discriminants must be absent — leaving them in
+	// would mean the dispatch is fixed in one place but not the other.
+	for _, src := range []string{store, sync, search} {
+		assert.NotContains(t, src, `case "audio_isolation":`,
+			"snake_case case string is the bug; only kebab-case should match the runtime resource")
+	}
+
+	// Compile and run the generated store package. The emitted
+	// TestUpsertBatch_PopulatesAudioIsolationTable calls
+	// UpsertBatch("audio-isolation", ...) against the kebab dispatch case;
+	// a regression that re-broke either side would fail here at runtime.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "test", "./internal/store")
+}
+
 // TestGenerateStoreSubResourceUpsertBindingOrder asserts that the typed
 // upsert for a sub-resource table binds its argument values in the same
 // order as the SQL column declarations. buildSubResourceTable puts the
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 58a054fb..28b005db 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -8,7 +8,11 @@ import (
 )
 
 type TableDef struct {
+	// Name is the snake_cased SQL/Go identifier (table DDL, Pascal-derived
+	// method names). Resource is the original spec key used by callers that
+	// dispatch on the runtime resource string, which preserves spec casing.
 	Name         string
+	Resource     string
 	Columns      []ColumnDef
 	Indexes      []IndexDef
 	FTS5         bool
@@ -65,8 +69,9 @@ func BuildSchema(s *spec.APISpec) []TableDef {
 		tableName := toSnakeCase(name)
 
 		table := TableDef{
-			Name:    tableName,
-			Columns: append([]ColumnDef(nil), baseTableColumns...),
+			Name:     tableName,
+			Resource: name,
+			Columns:  append([]ColumnDef(nil), baseTableColumns...),
 		}
 
 		if gravity >= 2 {
@@ -143,7 +148,10 @@ func BuildSchema(s *spec.APISpec) []TableDef {
 	// author naming a top-level resource the same thing the shard synthesizes
 	// (e.g. top-level "gists_commits" plus a multi-parent "commits" under
 	// "gists") would otherwise emit two CREATE TABLE statements and two
-	// duplicate Upsert<X>Tx methods, breaking the build on regen.
+	// duplicate Upsert<X>Tx methods, breaking the build on regen. Top-level
+	// tables are appended before sub-resource tables, so on a Name collision
+	// the kept entry carries the top-level Resource (raw spec key) rather
+	// than the sub-resource's snake-cased form.
 	seen := make(map[string]bool)
 	deduped := make([]TableDef, 0, len(tables))
 	for _, t := range tables {
@@ -155,7 +163,8 @@ func BuildSchema(s *spec.APISpec) []TableDef {
 	tables = deduped
 
 	tables = append(tables, TableDef{
-		Name: "sync_state",
+		Name:     "sync_state",
+		Resource: "sync_state",
 		Columns: []ColumnDef{
 			{Name: "resource_type", Type: "TEXT", PrimaryKey: true},
 			{Name: "last_cursor", Type: "TEXT"},
@@ -372,8 +381,9 @@ func buildSubResourceTable(name string, r spec.Resource, parentTable string) Tab
 	columns = append(columns, baseTableColumns[1:]...) // data, synced_at
 
 	return TableDef{
-		Name:    tableName,
-		Columns: columns,
+		Name:     tableName,
+		Resource: tableName,
+		Columns:  columns,
 		Indexes: []IndexDef{
 			{
 				Name:      "idx_" + tableName + "_" + parentCol,
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index 142ffc9f..f785e685 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -171,7 +171,7 @@ In local mode: searches locally synced data only.`,
 			switch resourceType {
 {{- range .Tables}}
 {{- if .FTS5}}
-			case "{{.Name}}":
+			case "{{.Resource}}":
 				results, err = db.Search{{pascal .Name}}(query, limit)
 {{- end}}
 {{- end}}
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index 232ae72c..ed754e3e 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -845,7 +845,7 @@ func (s *Store) Upsert{{pascal .Name}}(data json.RawMessage) error {
 	}
 	defer tx.Rollback()
 
-	if err := s.upsertGenericResourceTx(tx, "{{.Name}}", id, data); err != nil {
+	if err := s.upsertGenericResourceTx(tx, "{{.Resource}}", id, data); err != nil {
 		return err
 	}
 	if err := s.upsert{{pascal .Name}}Tx(tx, id, obj, data); err != nil {
@@ -944,7 +944,7 @@ func (s *Store) UpsertBatch(resourceType string, items []json.RawMessage) (int,
 		switch resourceType {
 {{- range .Tables}}
 {{- if hasTypedTable .}}
-		case "{{.Name}}":
+		case "{{.Resource}}":
 			if err := s.upsert{{pascal .Name}}Tx(tx, id, obj, item); err != nil {
 				return 0, extractFailures, fmt.Errorf("typed upsert for %s/%s: %w", resourceType, id, err)
 			}
diff --git a/internal/generator/templates/store_upsert_batch_test.go.tmpl b/internal/generator/templates/store_upsert_batch_test.go.tmpl
index 18495154..698a7fd9 100644
--- a/internal/generator/templates/store_upsert_batch_test.go.tmpl
+++ b/internal/generator/templates/store_upsert_batch_test.go.tmpl
@@ -288,14 +288,14 @@ func TestUpsertBatch_Populates{{pascal .Name}}Table(t *testing.T) {
 		json.RawMessage(`{"id": "test-002"}`),
 		json.RawMessage(`{"id": "test-003"}`),
 	}
-	if _, _, err := s.UpsertBatch("{{.Name}}", items); err != nil {
+	if _, _, err := s.UpsertBatch("{{.Resource}}", items); err != nil {
 		t.Fatalf("UpsertBatch: %v", err)
 	}
 
 	db := s.DB()
 
 	var generic int
-	if err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, "{{.Name}}").Scan(&generic); err != nil {
+	if err := db.QueryRow(`SELECT COUNT(*) FROM resources WHERE resource_type = ?`, "{{.Resource}}").Scan(&generic); err != nil {
 		t.Fatalf("count resources: %v", err)
 	}
 	if generic != len(items) {
@@ -329,7 +329,7 @@ func TestUpsertBatch_Sets{{pascal .Name}}ParentID(t *testing.T) {
 		json.RawMessage(`{"id": "child-002", "parent_id": "parent-A"}`),
 		json.RawMessage(`{"id": "child-003", "parent_id": "parent-B"}`),
 	}
-	if _, _, err := s.UpsertBatch("{{.Name}}", items); err != nil {
+	if _, _, err := s.UpsertBatch("{{.Resource}}", items); err != nil {
 		t.Fatalf("UpsertBatch: %v", err)
 	}
 
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 83e44547..0bee4ab4 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -958,7 +958,7 @@ func upsertSingleObject(db *store.Store, resource string, data json.RawMessage)
 	switch resource {
 {{- range .Tables}}
 {{- if hasTypedTable .}}
-	case "{{.Name}}":
+	case "{{.Resource}}":
 		return db.Upsert{{pascal .Name}}(data)
 {{- end}}
 {{- end}}

← 5c4ef3a7 fix(catalog): refresh google-flights entry to reflect fli na  ·  back to Cli Printing Press  ·  fix(cli): delegate workflow archive to syncResource (#1090) 3d515c45 →