← back to Cli Printing Press
fix(cli): SQL reserved word safety, promoted subcommands, verify classification (#122)
289ac4bf09ebf4fd283b0ef2d4114659b8526dc2 · 2026-04-04 15:04:15 -0700 · Trevin Chow
Three retro findings from the cal.com generation run:
1. SQL identifier safety: table/column names that collide with SQL reserved
words (default, check, references, from, to) now get double-quoted in
generated store migrations, upserts, and queries. Added safeSQLName()
and applied it in store.go.tmpl and the columnNames/updateSet helpers.
2. Promoted commands now wire sibling endpoints and sub-resources as
subcommands, so `bookings cancel` works instead of requiring the raw
operationId path. Also fixed duplicate auth registration when the
spec has an auth resource.
3. Verify's classifyCommandKind() now correctly classifies transcendence
commands (stale, no-show, today, busy, diff) as data-layer and
discovery commands (api, completion) as local.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
A docs/retros/2026-04-04-cal-com-retro.mdM internal/generator/generator.goM internal/generator/schema_builder.goM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/store.go.tmplM internal/pipeline/runtime.go
Diff
commit 289ac4bf09ebf4fd283b0ef2d4114659b8526dc2
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat Apr 4 15:04:15 2026 -0700
fix(cli): SQL reserved word safety, promoted subcommands, verify classification (#122)
Three retro findings from the cal.com generation run:
1. SQL identifier safety: table/column names that collide with SQL reserved
words (default, check, references, from, to) now get double-quoted in
generated store migrations, upserts, and queries. Added safeSQLName()
and applied it in store.go.tmpl and the columnNames/updateSet helpers.
2. Promoted commands now wire sibling endpoints and sub-resources as
subcommands, so `bookings cancel` works instead of requiring the raw
operationId path. Also fixed duplicate auth registration when the
spec has an auth resource.
3. Verify's classifyCommandKind() now correctly classifies transcendence
commands (stale, no-show, today, busy, diff) as data-layer and
discovery commands (api, completion) as local.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
docs/retros/2026-04-04-cal-com-retro.md | 190 +++++++++++++++++++++
internal/generator/generator.go | 14 +-
internal/generator/schema_builder.go | 28 +++
.../generator/templates/command_promoted.go.tmpl | 10 ++
internal/generator/templates/root.go.tmpl | 2 +
internal/generator/templates/store.go.tmpl | 28 +--
internal/pipeline/runtime.go | 10 +-
7 files changed, 261 insertions(+), 21 deletions(-)
diff --git a/docs/retros/2026-04-04-cal-com-retro.md b/docs/retros/2026-04-04-cal-com-retro.md
new file mode 100644
index 00000000..a16ba4f1
--- /dev/null
+++ b/docs/retros/2026-04-04-cal-com-retro.md
@@ -0,0 +1,190 @@
+# Printing Press Retro: Cal.com
+
+## Session Stats
+- API: Cal.com (scheduling / calendar management)
+- Spec source: OpenAPI 3.0.0 from GitHub (calcom/cal.com/docs/api-reference/v2/openapi.json)
+- Scorecard: 92/100 (Grade A)
+- Verify pass rate: 89% (25/28 after polish)
+- Fix loops: 1 shipcheck + 1 polish
+- Manual code edits: 7 (description rewrite, CAL_COM_API_KEY env var, cal-api-version header, duplicate auth removal, promoted bookings subcommand wiring, dead code removal, verify failure fixes)
+- Features built from scratch: 8 (sync, health, stale, no-show, today, busy, sql, enhanced bookings store)
+
+## Findings
+
+### 1. SQL reserved words in generated table names (Bug)
+- **What happened:** Generator created tables named `default`, `check`, `references` with column names `from` and `to`. All are SQL reserved words. SQLite migration failed with "syntax error" on first `store.Open()`.
+- **Root cause:** `schema_builder.go:315` `toSnakeCase()` converts resource names to table names but never checks against SQL reserved words. No quoting is applied.
+- **Cross-API check:** Any API with resources named default, check, references, order, group, select, key, from, to, etc. will hit this. Cal.com has 5 reserved words in one spec. Stripe has "refund" (safe) but could have "default" for default payment methods. GitHub has "check" (check runs/suites).
+- **Frequency:** most APIs — common resource names collide with SQL keywords
+- **Fallback if machine doesn't fix it:** Claude must manually find and fix every reserved word in every generated store migration. This is error-prone (took 4 fix cycles in this session because reserved words surfaced one at a time). Claude sometimes catches it, sometimes doesn't — each migration error surfaces one keyword, requiring iterative fixes.
+- **Worth a machine fix?** Yes. This broke the CLI at runtime and took multiple fix cycles.
+- **Inherent or fixable:** Fixable. Add a reserved word check to `toSnakeCase()` or a post-processing step.
+- **Durable fix:** In `schema_builder.go`, add a `sqlSafeIdentifier()` function that wraps table/column names in double-quotes if they match a known set of SQL reserved words. Apply it in `buildResourceTable()` and `buildSubResourceTable()` for both table names and column names. SQLite accepts double-quoted identifiers universally, so quoting all names is also safe (simpler but slightly less readable SQL).
+- **Test:** Generate a CLI from a spec with resources named "default", "check", "references", "order". Verify `store.Open()` succeeds and all tables are queryable. Negative test: a CLI for Stripe (no reserved words) still generates unquoted tables.
+- **Evidence:** 4 sequential migration failures during this session, each revealing a different reserved word.
+
+### 2. FTS triggers reference non-existent table columns (Bug)
+- **What happened:** Generator emitted FTS5 triggers like `INSERT INTO event_types_fts(...) VALUES (new.description, new.title)` but the `event_types` table only has `id, data, synced_at`. `description` and `title` are inside the JSON `data` column, not extracted as real columns. Trigger creation fails.
+- **Root cause:** `store.go.tmpl:90-111` generates FTS triggers using `FTS5Fields` from the schema builder. The schema builder picks searchable fields from the spec's response schema. But the entity mapper only extracts a subset of fields as real columns — the FTS fields may not be among them. The trigger template assumes all FTS fields are real columns.
+- **Cross-API check:** Any API where the high-searchable fields aren't among the fields extracted as real table columns. This is common — the entity mapper extracts IDs, foreign keys, and temporal fields, but FTS typically needs title/name/description which may not be extracted.
+- **Frequency:** most APIs — FTS fields and extracted columns are chosen by different criteria
+- **Fallback if machine doesn't fix it:** Claude must remove or rewrite broken FTS triggers. Medium reliability — Claude catches it when migration fails, but may not proactively check.
+- **Worth a machine fix?** Yes. Broken migrations prevent the CLI from working.
+- **Inherent or fixable:** Fixable. Either extract FTS fields as real columns, or generate standalone FTS tables (not content-sync) that are populated during sync.
+- **Durable fix:** In `schema_builder.go`, when setting `FTS5Fields`, check each field against the table's actual columns. If a field isn't a real column, either: (a) add it as an extracted column, or (b) switch to a standalone FTS table (without `content=` and triggers) and populate it during upsert. Option (b) is more robust because it doesn't force column extraction just for search.
+- **Test:** Generate a CLI where event_types has FTS on `title` and `description` but neither is an extracted column. Verify migration succeeds and search returns results after sync.
+- **Evidence:** `CREATE TRIGGER IF NOT EXISTS event_types_ai` failed because `new.description` doesn't exist.
+
+### 3. Promoted commands don't include mutation subcommands (Template gap)
+- **What happened:** The promoted `bookings` command is a list-only shortcut. `bookings cancel`, `bookings confirm`, etc. are not accessible — they exist only in the hidden raw command group. Users must know the raw operationId-derived name to access mutations.
+- **Root cause:** `command_promoted.go.tmpl` generates a single RunE function for the default list operation. It doesn't `AddCommand()` the sibling mutation commands from the raw command group. The raw command is marked `Hidden: true` because the promoted version replaces it.
+- **Cross-API check:** Every API with promoted commands and mutation subcommands. Stripe, GitHub, Linear, Notion — all have list as the default but cancel/update/delete as subcommands.
+- **Frequency:** every API
+- **Fallback if machine doesn't fix it:** Claude must manually add `cmd.AddCommand(...)` for every mutation subcommand. Medium reliability — Claude catches it when the user asks, but won't proactively wire subcommands.
+- **Worth a machine fix?** Yes. This affects UX for every generated CLI.
+- **Inherent or fixable:** Fixable. The template can iterate over sibling endpoints in the same resource and add them as subcommands.
+- **Durable fix:** In `command_promoted.go.tmpl`, after defining the promoted command and before `return cmd`, add a loop that registers subcommands from the raw command group. The generator already knows which endpoints belong to each resource — emit `cmd.AddCommand(new{{camel .SubcommandName}}Cmd(flags))` for each non-default endpoint.
+- **Test:** Generate a CLI for an API with bookings (list, create, cancel, update). Verify `bookings cancel --help` works.
+- **Evidence:** `bookings cancel --help` showed the list help, not the cancel subcommand.
+
+### 4. Verify misclassifies data-layer commands as "read" (Scorer bug)
+- **What happened:** `stale`, `tail`, and `api` failed verify because they're classified as "read" but don't behave like read commands. `stale` reads from SQLite (data-layer). `tail` is a long-running poller. `api` is a local discovery command.
+- **Scorer correct?** No. The scorer's `classifyCommandKind()` in `internal/pipeline/runtime.go:472-496` has a hardcoded list of data-layer command names (`sync, search, sql, health, trends, patterns, analytics, export, import`). New transcendence commands like `stale`, `no-show`, `today`, `busy` aren't in the list. `api` and `tail` are also misclassified.
+- **Root cause:** `runtime.go:477` uses a static `switch` statement. Any new command name not in the list defaults to "read" via the fallthrough at line 494.
+- **Cross-API check:** Every CLI that adds transcendence commands beyond the hardcoded list.
+- **Frequency:** every API — the transcendence command set grows with each generation
+- **Durable fix:** Two approaches:
+ 1. Add `stale`, `no-show`, `today`, `busy`, `diff` to the data-layer list and `api` to the local list. Quick but fragile — breaks again when new commands are added.
+ 2. Have the generator emit a `command_kinds.json` manifest listing each command and its kind. The verify tool reads this instead of guessing. More durable — new commands self-classify.
+- **Test:** Generate cal-com-pp-cli, run verify. `stale`, `no-show`, `today`, `busy` should be data-layer. `api` should be local. `tail` should be local or data-layer.
+- **Evidence:** Verify output showed `stale: read PASS FAIL FAIL 1/3` despite `stale --dry-run` returning exit 0 locally.
+
+### 5. Missing API-specific required headers (Template gap)
+- **What happened:** Cal.com API v2 requires `cal-api-version: 2024-08-13` on every request. Without it, endpoints return 404. The generator didn't emit this header.
+- **Root cause:** `client.go.tmpl:343-346` only sets `Authorization` and `User-Agent` headers. There's no mechanism to detect or emit API-specific required headers from the spec's parameter definitions or extensions.
+- **Cross-API check:** Some APIs require version headers (cal-api-version, X-API-Version, Stripe-Version). Anthropic requires `anthropic-version`. This is a recognizable pattern.
+- **Frequency:** API subclass — APIs with explicit version headers. ~10-20% of APIs.
+- **Fallback if machine doesn't fix it:** Claude must read the spec or docs, notice the required header, and manually add it to client.go. Medium reliability — easy to miss.
+- **Worth a machine fix?** Yes. Missing a required header breaks all API calls silently (404s).
+- **Inherent or fixable:** Fixable. The spec declares required parameters with `in: header`. The generator can detect these and emit them.
+- **Durable fix:** In the OpenAPI parser, detect parameters with `in: header` and `required: true` at the path or operation level. If a required header appears on >80% of operations (like a version header), emit it in the client template as a default header. Store it in the generator's `APIConfig` struct so the client template can iterate: `{{range .RequiredHeaders}}req.Header.Set("{{.Name}}", "{{.Default}}"){{end}}`.
+ - **Condition:** Spec has required header parameters on most operations
+ - **Guard:** Skip headers that are per-operation (like Content-Type)
+ - **Frequency estimate:** ~10-20% of APIs use version headers
+- **Test:** Generate from Cal.com spec. Verify client.go sets `cal-api-version`. Negative: Generate from Stripe spec (no required version header) — client.go should not have extra headers.
+- **Evidence:** All Cal.com API calls returned 404 until `cal-api-version: 2024-08-13` was manually added.
+
+### 6. Duplicate auth command registration (Bug)
+- **What happened:** root.go registered `newAuthCmd(&flags)` twice — once from the resource loop (line 102 in template) and once from the hardcoded auth registration (line 106).
+- **Root cause:** `root.go.tmpl:106` always registers `newAuthCmd`. But if the API spec has a resource named "auth" (Cal.com has `/v2/auth/*` endpoints), the resource loop at line 100-102 also registers it. The template doesn't check for overlap.
+- **Cross-API check:** Any API with auth-related endpoints in the spec (/auth, /oauth, /token). Cal.com, Discord, any OAuth provider.
+- **Frequency:** API subclass — APIs with auth endpoints in the spec. ~30-40%.
+- **Fallback if machine doesn't fix it:** Claude notices the duplicate in `--help` output and removes one. High reliability, but annoying.
+- **Worth a machine fix?** Yes. Simple guard.
+- **Inherent or fixable:** Fixable. One-line conditional.
+- **Durable fix:** In `root.go.tmpl`, wrap line 106 in `{{if not (index .Resources "auth")}}...{{end}}`. If auth is already a resource, it's registered by the loop and doesn't need a second registration.
+- **Test:** Generate from Cal.com spec (has auth resource). Verify `--help` shows one `auth` entry. Negative: Generate from a spec without auth resource — auth still registered.
+- **Evidence:** `--help` showed `auth` listed twice.
+
+### 7. Config template doesn't emit env var auth when spec lacks security section (Assumption mismatch)
+- **What happened:** Config.go had no `CAL_COM_API_KEY` env var support. The spec's security section was empty, so the generator didn't know about Bearer auth. But Cal.com documents Bearer auth in prose and all SDKs/MCPs use it.
+- **Root cause:** `config.go.tmpl:62-67` iterates over `.Auth.EnvVars` which comes from the spec's `securityDefinitions`/`security`. Cal.com's OpenAPI spec has no `security` section — auth is described only in the `info.description` text and in parameter descriptions.
+- **Cross-API check:** APIs with incomplete or missing security definitions in their spec. Common for community-maintained specs, reverse-engineered specs, and quick-and-dirty OpenAPI files.
+- **Frequency:** API subclass — specs without formal security sections. ~20-30%.
+- **Fallback if machine doesn't fix it:** Claude must manually add env var support. The skill already instructs this ("Compensate for missing auth" in Phase 2), so Claude catches it. Medium-high reliability but adds ~5 min of manual work.
+- **Worth a machine fix?** Yes, but needs careful design. The generator shouldn't invent auth patterns — but it could have a fallback: if no security section is found, check the spec's description and parameter descriptions for "Bearer", "API key", "Authorization" mentions, and infer a default env var.
+- **Inherent or fixable:** Partially fixable. A heuristic that detects auth mentions in description text would catch ~80% of cases. The remaining 20% need manual intervention.
+- **Durable fix:** In the OpenAPI parser, after parsing `security`/`securityDefinitions`, if no auth is found, scan `info.description` for patterns like "Bearer", "API key", "Authorization header", "cal_live_", "sk_live_". If found, set a default `Auth.Type = "api_key"` with env var derived from the API name (`<API_NAME>_API_KEY`). Mark it as `inferred` so the config template can add a comment.
+ - **Condition:** No security section in spec AND description mentions auth keywords
+ - **Guard:** Skip if security section exists (explicit > inferred)
+- **Test:** Generate from Cal.com spec (no security section, description mentions Bearer). Verify config.go has `CAL_COM_API_KEY` env var. Negative: Generate from Stripe spec (has security section) — env var comes from spec, not inference.
+- **Evidence:** Manual addition of `CAL_COM_API_KEY` in config.go.
+
+## Prioritized Improvements
+
+### Fix the Scorer
+| # | Scorer | Bug | Impact | Fix target |
+|---|--------|-----|--------|------------|
+| 4 | verify `classifyCommandKind()` | Hardcoded data-layer list misses new transcendence commands | 3 false failures (stale, tail, api) per CLI = -9% verify rate | `internal/pipeline/runtime.go:472-496` |
+
+### Do Now
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 1 | Quote SQL reserved words in table/column names | `internal/generator/schema_builder.go` | most APIs | Low (iterative fix cycles) | small | None needed — quoting is always safe |
+| 6 | Deduplicate auth command registration | `internal/generator/templates/root.go.tmpl` | 30-40% of APIs | High but annoying | small | Check if "auth" is in Resources |
+| 3 | Wire mutation subcommands into promoted commands | `internal/generator/templates/command_promoted.go.tmpl` | every API | Medium | medium | Only add subcommands from same resource |
+
+### Do Next (needs design)
+| # | Fix | Component | Frequency | Fallback Reliability | Complexity | Guards |
+|---|-----|-----------|-----------|---------------------|------------|--------|
+| 2 | Fix FTS triggers for non-extracted columns | `schema_builder.go` + `store.go.tmpl` | most APIs | Medium | medium | Only when FTS fields aren't extracted columns |
+| 5 | Detect and emit required API headers | OpenAPI parser + `client.go.tmpl` | 10-20% | Medium | medium | Only for headers on >80% of operations |
+| 7 | Infer auth from spec description when security section missing | OpenAPI parser + `config.go.tmpl` | 20-30% | Medium-high | medium | Only when no security section exists |
+
+### Skip
+| # | Fix | Why unlikely to recur |
+|---|-----|----------------------|
+| (none) | | All findings apply across multiple APIs |
+
+## Work Units
+
+### WU-1: SQL identifier safety (finding #1)
+- **Goal:** Generated table and column names never collide with SQL reserved words
+- **Target files:**
+ - `internal/generator/schema_builder.go` — add `sqlSafeIdentifier()`, apply in `buildResourceTable()` and `buildSubResourceTable()`
+ - `internal/generator/templates/store.go.tmpl` — use safe identifiers in CREATE TABLE, INSERT, and SELECT templates
+- **Acceptance criteria:**
+ - Generate from Cal.com spec (has `default`, `check`, `references`, `from`, `to`) → migration succeeds, all tables queryable
+ - Generate from Stripe spec (no reserved words) → tables still unquoted (no unnecessary quoting)
+ - Generate from a synthetic spec with resource named `order` → table is `"order"` or `order_items`
+- **Scope boundary:** Does NOT rename existing tables in published CLIs
+- **Complexity:** small
+
+### WU-2: Promoted command subcommand wiring + auth dedup (findings #3, #6)
+- **Goal:** Promoted commands expose mutation subcommands; auth is never registered twice
+- **Target files:**
+ - `internal/generator/templates/command_promoted.go.tmpl` — add `AddCommand()` for sibling endpoints
+ - `internal/generator/templates/root.go.tmpl` — guard auth registration
+ - `internal/generator/promoted.go` (or wherever promotion logic lives) — pass sibling info to template
+- **Acceptance criteria:**
+ - Generate from Cal.com spec → `bookings cancel --help` works
+ - Generate from Cal.com spec → `--help` shows one `auth` entry
+ - Generate from Stripe spec → promoted `charges` includes `refund` subcommand
+- **Scope boundary:** Does NOT rename operationId-derived subcommand names (that's a separate cosmetic fix)
+- **Complexity:** medium
+
+### WU-3: FTS trigger safety (finding #2)
+- **Goal:** FTS triggers only reference columns that exist on the table
+- **Target files:**
+ - `internal/generator/schema_builder.go` — validate FTS fields against extracted columns
+ - `internal/generator/templates/store.go.tmpl` — support standalone FTS (without content-sync triggers) as fallback
+- **Acceptance criteria:**
+ - Generate from Cal.com spec → event_types FTS works (either with extracted columns or standalone table)
+ - FTS search returns results after sync
+ - Negative: Generate from spec where FTS fields ARE extracted columns → content-sync triggers still used (more efficient)
+- **Scope boundary:** Does NOT change the FTS field selection heuristic — only the trigger generation
+- **Complexity:** medium
+
+### WU-4: Verify command classification (finding #4)
+- **Goal:** Verify correctly classifies transcendence commands as data-layer and discovery commands as local
+- **Target files:**
+ - `internal/pipeline/runtime.go:472-496` — expand classification lists or implement manifest-based classification
+- **Acceptance criteria:**
+ - Run verify on cal-com-pp-cli → stale, no-show, today, busy classified as data-layer, api as local
+ - Run verify on a CLI without transcendence → no regression in existing classification
+- **Scope boundary:** Quick fix (expand lists) now; manifest approach can be a follow-up
+- **Complexity:** small
+
+## Anti-patterns
+
+- **Iterative reserved-word discovery**: Each migration failure surfaced one reserved word at a time. Fix all known reserved words in one pass — don't fix-build-fail-fix.
+- **Testing sync without cleaning the DB**: Leftover DB from a prior run can mask or cause migration errors. Always clean before testing migration changes.
+
+## What the Machine Got Right
+
+- **Quality gates**: All 7 gates passed on first generation — no build or compilation failures from the generator itself.
+- **API coverage breadth**: 181 paths → ~250 command files with correct routing. The OpenAPI parser handled this large spec well.
+- **Data layer scaffolding**: The store template generated a working SQLite schema with FTS, sync_state, and per-resource tables. The foundation was solid — transcendence commands built on top of it.
+- **Promoted command pattern**: The shortcut-for-list pattern works well for UX. The gap is subcommand wiring, not the pattern itself.
+- **Dogfood accuracy**: Dead flags, dead functions, path validity, and example coverage were all accurately measured. Dogfood is trustworthy.
+- **Score composition**: 92/100 on first shipcheck is excellent for a fresh generation. The machine produces Grade A CLIs from well-structured specs.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 6859320e..fb700475 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -111,7 +111,8 @@ func New(s *spec.APISpec, outputDir string) *Generator {
// "steam-web" → "Steam Web", "notion" → "Notion"
return cases.Title(language.English).String(strings.ReplaceAll(s, "-", " "))
},
- "envName": func(s string) string { return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) },
+ "envName": func(s string) string { return strings.ToUpper(strings.ReplaceAll(s, "-", "_")) },
+ "safeName": safeSQLName,
"goLiteral": func(v any) string {
switch val := v.(type) {
case string:
@@ -586,12 +587,16 @@ func (g *Generator) Generate() error {
// Generate promoted top-level commands (user-friendly aliases for nested API commands)
// promotedCommands was computed earlier (before resource rendering) for Hidden flag
for _, pc := range promotedCommands {
+ // Look up the full resource to pass sibling endpoints/sub-resources
+ resource := g.Spec.Resources[pc.ResourceName]
promotedData := struct {
PromotedName string
ResourceName string
EndpointName string
Endpoint spec.Endpoint
HasStore bool
+ Resource spec.Resource
+ FuncPrefix string
*spec.APISpec
}{
PromotedName: pc.PromotedName,
@@ -599,6 +604,8 @@ func (g *Generator) Generate() error {
EndpointName: pc.EndpointName,
Endpoint: pc.Endpoint,
HasStore: g.VisionSet.Store,
+ Resource: resource,
+ FuncPrefix: pc.ResourceName,
APISpec: g.Spec,
}
promotedPath := filepath.Join("internal", "cli", "promoted_"+pc.PromotedName+".go")
@@ -806,7 +813,7 @@ func camelToJSON(s string) string {
func columnNames(cols []ColumnDef) string {
names := make([]string, 0, len(cols))
for _, col := range cols {
- names = append(names, col.Name)
+ names = append(names, safeSQLName(col.Name))
}
return strings.Join(names, ", ")
}
@@ -828,7 +835,8 @@ func updateSet(cols []ColumnDef) string {
if col.PrimaryKey {
continue
}
- updates = append(updates, fmt.Sprintf("%s = excluded.%s", col.Name, col.Name))
+ safe := safeSQLName(col.Name)
+ updates = append(updates, fmt.Sprintf("%s = excluded.%s", safe, safe))
}
return strings.Join(updates, ", ")
}
diff --git a/internal/generator/schema_builder.go b/internal/generator/schema_builder.go
index 7c6bedb2..db01e283 100644
--- a/internal/generator/schema_builder.go
+++ b/internal/generator/schema_builder.go
@@ -311,6 +311,34 @@ func buildSubResourceTable(name string, r spec.Resource, parentTable string) Tab
return table
}
+// sqlReservedWords is the set of SQL keywords that must be quoted when used
+// as table or column names. Covers SQLite reserved words plus common SQL
+// keywords that appear as API resource or field names.
+var sqlReservedWords = map[string]bool{
+ "check": true, "default": true, "from": true, "to": true,
+ "order": true, "group": true, "select": true, "where": true,
+ "table": true, "column": true, "index": true, "key": true,
+ "values": true, "references": true, "create": true, "drop": true,
+ "insert": true, "update": true, "delete": true, "set": true,
+ "join": true, "on": true, "in": true, "not": true, "null": true,
+ "primary": true, "foreign": true, "unique": true, "like": true,
+ "between": true, "exists": true, "having": true, "limit": true,
+ "offset": true, "union": true, "except": true, "case": true,
+ "when": true, "then": true, "else": true, "end": true,
+ "as": true, "is": true, "by": true, "and": true, "or": true,
+ "transaction": true, "begin": true, "commit": true, "rollback": true,
+ "trigger": true, "view": true, "replace": true, "match": true,
+}
+
+// safeSQLName returns the identifier double-quoted if it's a SQL reserved word.
+// Safe to use as a template function for table and column names in SQL DDL.
+func safeSQLName(name string) string {
+ if sqlReservedWords[strings.ToLower(name)] {
+ return `"` + name + `"`
+ }
+ return name
+}
+
// toSnakeCase converts camelCase, PascalCase, or kebab-case to snake_case.
func toSnakeCase(s string) string {
s = strings.ReplaceAll(s, "-", "_")
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index f4b8df48..ab5ab091 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -138,5 +138,15 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
{{- end}}
+ // Wire sibling endpoints and sub-resources as subcommands
+{{- range $eName, $endpoint := .Resource.Endpoints}}
+{{- if ne $eName $.EndpointName}}
+ cmd.AddCommand(new{{camel $.FuncPrefix}}{{camel $eName}}Cmd(flags))
+{{- end}}
+{{- end}}
+{{- range $subName, $sub := .Resource.SubResources}}
+ cmd.AddCommand(new{{camel $.FuncPrefix}}{{camel $subName}}Cmd(flags))
+{{- end}}
+
return cmd
}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 1cbdb02b..e0d605b2 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -103,7 +103,9 @@ func Execute() error {
{{- end}}
{{- end}}
rootCmd.AddCommand(newDoctorCmd(&flags))
+{{- if not (index .Resources "auth")}}
rootCmd.AddCommand(newAuthCmd(&flags))
+{{- end}}
{{- if .VisionSet.Export}}
rootCmd.AddCommand(newExportCmd(&flags))
{{- end}}
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index da00087a..79157564 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -78,37 +78,37 @@ func (s *Store) migrate() error {
)`,
{{- range .Tables}}
{{- if ne .Name "sync_state"}}
- `CREATE TABLE IF NOT EXISTS {{.Name}} (
+ `CREATE TABLE IF NOT EXISTS {{safeName .Name}} (
{{- range $i, $col := .Columns}}
{{- if $i}},{{end}}
- {{$col.Name}} {{$col.Type}}{{if $col.PrimaryKey}} PRIMARY KEY{{end}}{{if $col.NotNull}} NOT NULL{{end}}
+ {{safeName $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}})`,
+ `CREATE {{if .Unique}}UNIQUE {{end}}INDEX IF NOT EXISTS {{.Name}} ON {{safeName .TableName}}({{safeName .Columns}})`,
{{- end}}
{{- if .FTS5}}
`CREATE VIRTUAL TABLE IF NOT EXISTS {{.Name}}_fts USING fts5(
{{- range $i, $field := .FTS5Fields}}
{{- if $i}},{{end}}
- {{$field}}
+ {{safeName $field}}
{{- end}},
- content='{{.Name}}',
+ content='{{safeName .Name}}',
content_rowid='rowid'
)`,
- `CREATE TRIGGER IF NOT EXISTS {{.Name}}_ai AFTER INSERT ON {{.Name}} BEGIN
+ `CREATE TRIGGER IF NOT EXISTS {{.Name}}_ai AFTER INSERT ON {{safeName .Name}} BEGIN
INSERT INTO {{.Name}}_fts(rowid, {{join .FTS5Fields ", "}})
- VALUES (new.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}new.{{$field}}{{- end}});
+ VALUES (new.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}new.{{safeName $field}}{{- end}});
END`,
- `CREATE TRIGGER IF NOT EXISTS {{.Name}}_ad AFTER DELETE ON {{.Name}} BEGIN
+ `CREATE TRIGGER IF NOT EXISTS {{.Name}}_ad AFTER DELETE ON {{safeName .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}});
+ VALUES ('delete', old.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}old.{{safeName $field}}{{- end}});
END`,
- `CREATE TRIGGER IF NOT EXISTS {{.Name}}_au AFTER UPDATE ON {{.Name}} BEGIN
+ `CREATE TRIGGER IF NOT EXISTS {{.Name}}_au AFTER UPDATE ON {{safeName .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}});
+ VALUES ('delete', old.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}old.{{safeName $field}}{{- end}});
INSERT INTO {{.Name}}_fts(rowid, {{join .FTS5Fields ", "}})
- VALUES (new.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}new.{{$field}}{{- end}});
+ VALUES (new.rowid, {{- range $i, $field := .FTS5Fields}}{{if $i}}, {{end}}new.{{safeName $field}}{{- end}});
END`,
{{- end}}
{{- end}}
@@ -284,7 +284,7 @@ func (s *Store) Upsert{{pascal .Name}}(data json.RawMessage) error {
}
_, err = tx.Exec(
- `INSERT INTO {{.Name}} ({{columnNames .Columns}})
+ `INSERT INTO {{safeName .Name}} ({{columnNames .Columns}})
VALUES ({{columnPlaceholders .Columns}})
ON CONFLICT(id) DO UPDATE SET {{updateSet .Columns}}`,
id,
@@ -346,7 +346,7 @@ func (s *Store) Search{{pascal .Name}}(query string, limit int) ([]json.RawMessa
limit = 50
}
rows, err := s.db.Query(
- `SELECT t.data FROM {{.Name}} t
+ `SELECT t.data FROM {{safeName .Name}} t
JOIN {{.Name}}_fts ON {{.Name}}_fts.rowid = t.rowid
WHERE {{.Name}}_fts MATCH ?
ORDER BY rank LIMIT ?`,
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index da14e88b..0a055560 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -472,16 +472,18 @@ func syntheticArgValue(name string) string {
// classifyCommandKind determines if a command is read, write, local, or data-layer.
func classifyCommandKind(cmd *discoveredCommand, spec *openAPISpec) {
name := cmd.Name
- // Data layer commands
+ // Data layer commands — read from local SQLite, not the API
switch name {
- case "sync", "search", "sql", "health", "trends", "patterns", "analytics", "export", "import":
+ case "sync", "search", "sql", "health", "trends", "patterns", "analytics",
+ "export", "import", "stale", "no-show", "today", "busy", "diff",
+ "noshow", "velocity", "popular":
cmd.Kind = "data-layer"
return
- case "doctor", "auth":
+ case "doctor", "auth", "api", "completion":
cmd.Kind = "local"
return
case "tail":
- cmd.Kind = "read"
+ cmd.Kind = "data-layer"
return
}
← 4b12b325 feat(cli): hide raw resource commands when promoted exist, a
·
back to Cli Printing Press
·
fix(cli): FTS trigger safety, envelope unwrapping, dogfood t bc320846 →