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