← back to Cli Printing Press
fix(cli): isolate generic resources by type (#901)
ff7553169890c8517bd174b0eef90cebbca010db · 2026-05-10 12:38:30 -0700 · Trevin Chow
* fix(cli): isolate generic resources by type
* fix(cli): disambiguate similar resources
Files touched
M internal/generator/generator_test.goM internal/generator/templates/insights/similar.go.tmplM internal/generator/templates/store.go.tmplM internal/generator/templates/store_schema_version_test.go.tmpl
Diff
commit ff7553169890c8517bd174b0eef90cebbca010db
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 12:38:30 2026 -0700
fix(cli): isolate generic resources by type (#901)
* fix(cli): isolate generic resources by type
* fix(cli): disambiguate similar resources
---
internal/generator/generator_test.go | 83 ++++++++++
.../generator/templates/insights/similar.go.tmpl | 37 ++++-
internal/generator/templates/store.go.tmpl | 168 +++++++++++++++++++--
.../templates/store_schema_version_test.go.tmpl | 154 ++++++++++++++++++-
4 files changed, 420 insertions(+), 22 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 3913caf8..bbc1c17d 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2557,6 +2557,89 @@ func TestGenerateStoreUpsertBatchDispatchesToTypedTable(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/store")
}
+func TestGenerateSimilarCommandUsesCompositeResourceKey(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("similar-composite")
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ gen.VisionSet = VisionTemplateSet{
+ Store: true,
+ Insights: []string{"insights/similar.go.tmpl"},
+ }
+ require.NoError(t, gen.Generate())
+
+ inlineTest := `package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "` + naming.CLI(apiSpec.Name) + `/internal/store"
+)
+
+func TestSimilarDisambiguatesOverlappingIDs(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ db, err := store.Open(dbPath)
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ if err := db.Upsert("biz", "shared", []byte(` + "`" + `{"id":"shared","name":"Pinky restaurant"}` + "`" + `)); err != nil {
+ t.Fatalf("upsert biz: %v", err)
+ }
+ if err := db.Upsert("bookmark", "shared", []byte(` + "`" + `{"id":"shared","name":"Anniversary bookmark"}` + "`" + `)); err != nil {
+ t.Fatalf("upsert bookmark: %v", err)
+ }
+ if err := db.Upsert("biz", "other", []byte(` + "`" + `{"id":"other","name":"Pinky restaurant north"}` + "`" + `)); err != nil {
+ t.Fatalf("upsert peer: %v", err)
+ }
+ db.Close()
+
+ cmd := newSimilarCmd(&rootFlags{asJSON: true})
+ cmd.SetArgs([]string{"shared", "--db", dbPath})
+ err = cmd.Execute()
+ if err == nil || !strings.Contains(err.Error(), "matches multiple resource types") {
+ t.Fatalf("similar without --type err = %v, want overlap error", err)
+ }
+
+ cmd = newSimilarCmd(&rootFlags{asJSON: true})
+ var out bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetArgs([]string{"shared", "--db", dbPath, "--type", "biz"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("similar with --type: %v", err)
+ }
+
+ var got struct {
+ SourceType string ` + "`" + `json:"source_type"` + "`" + `
+ Similar []struct {
+ ID string ` + "`" + `json:"id"` + "`" + `
+ ResourceType string ` + "`" + `json:"resource_type"` + "`" + `
+ } ` + "`" + `json:"similar"` + "`" + `
+ }
+ if err := json.Unmarshal(out.Bytes(), &got); err != nil {
+ t.Fatalf("parse JSON: %v\n%s", err, out.String())
+ }
+ if got.SourceType != "biz" {
+ t.Fatalf("source_type = %q, want biz", got.SourceType)
+ }
+ for _, item := range got.Similar {
+ if item.ID == "shared" && item.ResourceType == "biz" {
+ t.Fatalf("source item was not excluded exactly: %+v", got.Similar)
+ }
+ }
+}
+`
+ testPath := filepath.Join(outputDir, "internal", "cli", "similar_composite_key_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestSimilarDisambiguatesOverlappingIDs")
+}
+
func TestLiveFetchWriteThroughCachePopulatesTypedTable(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/insights/similar.go.tmpl b/internal/generator/templates/insights/similar.go.tmpl
index 9c7fef47..7c5e0652 100644
--- a/internal/generator/templates/insights/similar.go.tmpl
+++ b/internal/generator/templates/insights/similar.go.tmpl
@@ -4,6 +4,7 @@
package cli
import (
+ "database/sql"
"encoding/json"
"fmt"
@@ -14,6 +15,7 @@ import (
func newSimilarCmd(flags *rootFlags) *cobra.Command {
var dbPath string
var limit int
+ var resourceType string
cmd := &cobra.Command{
Use: "similar <item-id>",
@@ -24,6 +26,9 @@ work items, tickets, or tasks.`,
Example: ` # Find items similar to a given item
{{.Name}}-pp-cli similar abc123
+ # Disambiguate when multiple resource types share the same ID
+ {{.Name}}-pp-cli similar abc123 --type issues
+
# Limit results
{{.Name}}-pp-cli similar abc123 --limit 5
@@ -43,9 +48,29 @@ work items, tickets, or tasks.`,
}
defer db.Close()
- // Get the source item's content
+ // Get the source item's content.
var sourceData []byte
- err = db.QueryRow(`SELECT data FROM resources WHERE id = ?`, itemID).Scan(&sourceData)
+ var sourceType string
+ if resourceType != "" {
+ sourceType = resourceType
+ err = db.DB().QueryRow(`SELECT data FROM resources WHERE resource_type = ? AND id = ?`, sourceType, itemID).Scan(&sourceData)
+ } else {
+ var matchCount int
+ err = db.DB().QueryRow(
+ `SELECT resource_type, data, (SELECT COUNT(*) FROM resources WHERE id = ?) AS matches
+ FROM resources
+ WHERE id = ?
+ ORDER BY resource_type
+ LIMIT 1`,
+ itemID, itemID,
+ ).Scan(&sourceType, &sourceData, &matchCount)
+ if err == nil && matchCount > 1 {
+ return fmt.Errorf("item %q matches multiple resource types; rerun with --type", itemID)
+ }
+ }
+ if err == sql.ErrNoRows {
+ return fmt.Errorf("item %q not found in local store", itemID)
+ }
if err != nil {
return fmt.Errorf("item %q not found in local store: %w", itemID, err)
}
@@ -74,13 +99,13 @@ work items, tickets, or tasks.`,
// Use FTS5 to find similar items (exclude the source item)
query := `SELECT r.id, r.resource_type, r.data, rank
FROM resources_fts fts
- JOIN resources r ON r.id = fts.id
+ JOIN resources r ON r.id = fts.id AND r.resource_type = fts.resource_type
WHERE resources_fts MATCH ?
- AND r.id != ?
+ AND NOT (r.id = ? AND r.resource_type = ?)
ORDER BY rank
LIMIT ?`
- rows, err := db.Query(query, searchText, itemID, limit)
+ rows, err := db.DB().Query(query, searchText, itemID, sourceType, limit)
if err != nil {
return fmt.Errorf("searching for similar items: %w", err)
}
@@ -126,6 +151,7 @@ work items, tickets, or tasks.`,
if flags.asJSON {
result := map[string]any{
"source_id": itemID,
+ "source_type": sourceType,
"search_text": searchText,
"similar": items,
"result_count": len(items),
@@ -161,6 +187,7 @@ work items, tickets, or tasks.`,
cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-pp-cli/data.db)")
cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of similar items to show")
+ cmd.Flags().StringVar(&resourceType, "type", "", "Resource type for the source item when IDs overlap")
return cmd
}
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index 3c485d79..7793a51e 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -34,7 +34,11 @@ func IsUUID(s string) bool {
// shape — adding columns, dropping indexes, changing FTS5 tokenizers —
// so an older binary refuses to open a newer database rather than silently
// producing wrong results against a schema it cannot read.
-const StoreSchemaVersion = 1
+const StoreSchemaVersion = 2
+
+const resourcesFTSCreateSQL = `CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
+ id, resource_type, content, tokenize='porter unicode61'
+)`
type Store struct {
db *sql.DB
@@ -243,11 +247,12 @@ func (s *Store) migrate(ctx context.Context) error {
migrations := []string{
`CREATE TABLE IF NOT EXISTS resources (
- id TEXT PRIMARY KEY,
+ id TEXT NOT NULL,
resource_type TEXT NOT NULL,
data JSON NOT NULL,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
)`,
`CREATE INDEX IF NOT EXISTS idx_resources_type ON resources(resource_type)`,
`CREATE INDEX IF NOT EXISTS idx_resources_synced ON resources(synced_at)`,
@@ -257,9 +262,7 @@ func (s *Store) migrate(ctx context.Context) error {
last_synced_at DATETIME,
total_count INTEGER DEFAULT 0
)`,
- `CREATE VIRTUAL TABLE IF NOT EXISTS resources_fts USING fts5(
- id, resource_type, content, tokenize='porter unicode61'
- )`,
+ resourcesFTSCreateSQL,
{{- range .Tables}}
{{- /* Gate: only create tables that have a typed Upsert. Otherwise the
table is dead — UpsertBatch's dispatch skips it and inserts go only
@@ -337,6 +340,12 @@ func (s *Store) migrate(ctx context.Context) error {
return fmt.Errorf("database schema version %d is newer than supported version %d; upgrade the CLI binary or open an older database", current, StoreSchemaVersion)
}
+ if current < 2 {
+ if err := s.migrateResourcesCompositeKey(ctx, conn); err != nil {
+ return fmt.Errorf("migrating resources composite key: %w", err)
+ }
+ }
+
if err := s.backfillColumns(ctx, conn); err != nil {
return fmt.Errorf("backfilling columns: %w", err)
}
@@ -345,11 +354,12 @@ func (s *Store) migrate(ctx context.Context) error {
return fmt.Errorf("migration failed: %w", err)
}
}
- // Stamp the schema version. On a fresh DB this writes 1; on an
- // already-stamped DB this is a no-op write of the same value.
- // An older DB with user_version = 0 and pre-existing tables
- // gets stamped here without any data rewrites because the
- // migrations above are idempotent via CREATE TABLE IF NOT EXISTS.
+ // Stamp the schema version. On a fresh DB this writes the current
+ // StoreSchemaVersion; on an already-stamped DB this is a no-op
+ // write of the same value.
+ // An older DB with user_version = 0 and pre-existing tables gets
+ // stamped here after any version-gated rewrites and idempotent
+ // CREATE TABLE IF NOT EXISTS statements have completed.
if _, err := conn.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, StoreSchemaVersion)); err != nil {
return fmt.Errorf("stamp user_version: %w", err)
}
@@ -357,6 +367,128 @@ func (s *Store) migrate(ctx context.Context) error {
})
}
+func (s *Store) migrateResourcesCompositeKey(ctx context.Context, conn *sql.Conn) error {
+ exists, err := tableExists(ctx, conn, "resources")
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return nil
+ }
+
+ composite, err := resourcesTableHasCompositeKey(ctx, conn)
+ if err != nil {
+ return err
+ }
+ if !composite {
+ if _, err := conn.ExecContext(ctx, `CREATE TABLE resources_v2 (
+ id TEXT NOT NULL,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (resource_type, id)
+ )`); err != nil {
+ return fmt.Errorf("creating resources_v2: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, `INSERT INTO resources_v2 (id, resource_type, data, synced_at, updated_at)
+ SELECT id, resource_type, data, synced_at, updated_at FROM resources`); err != nil {
+ return fmt.Errorf("copying resources rows: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, `DROP TABLE resources`); err != nil {
+ return fmt.Errorf("dropping old resources table: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, `ALTER TABLE resources_v2 RENAME TO resources`); err != nil {
+ return fmt.Errorf("renaming resources_v2: %w", err)
+ }
+ }
+
+ // Always rebuild FTS during the v2 transition. The resources table may
+ // already have the composite key, but v1 FTS rowids were scoped by id
+ // alone and must be replaced with resource_type + id rowids.
+ if _, err := conn.ExecContext(ctx, `DROP TABLE IF EXISTS resources_fts`); err != nil {
+ return fmt.Errorf("dropping resources_fts: %w", err)
+ }
+ if _, err := conn.ExecContext(ctx, resourcesFTSCreateSQL); err != nil {
+ return fmt.Errorf("creating resources_fts: %w", err)
+ }
+ if err := rebuildResourcesFTS(ctx, conn); err != nil {
+ return fmt.Errorf("rebuilding resources_fts: %w", err)
+ }
+ return nil
+}
+
+func tableExists(ctx context.Context, conn *sql.Conn, name string) (bool, error) {
+ var count int
+ if err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&count); err != nil {
+ return false, fmt.Errorf("checking table %s: %w", name, err)
+ }
+ return count > 0, nil
+}
+
+func resourcesTableHasCompositeKey(ctx context.Context, conn *sql.Conn) (bool, error) {
+ rows, err := conn.QueryContext(ctx, `PRAGMA table_info(resources)`)
+ if err != nil {
+ return false, fmt.Errorf("reading resources table info: %w", err)
+ }
+ defer rows.Close()
+
+ pk := map[string]int{}
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pkOrder int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pkOrder); err != nil {
+ return false, fmt.Errorf("scanning resources table info: %w", err)
+ }
+ pk[name] = pkOrder
+ }
+ if err := rows.Err(); err != nil {
+ return false, fmt.Errorf("reading resources table info rows: %w", err)
+ }
+ return pk["resource_type"] == 1 && pk["id"] == 2, nil
+}
+
+func rebuildResourcesFTS(ctx context.Context, conn *sql.Conn) error {
+ rows, err := conn.QueryContext(ctx, `SELECT id, resource_type, data FROM resources`)
+ if err != nil {
+ return fmt.Errorf("querying resources: %w", err)
+ }
+
+ type resourceRow struct {
+ id string
+ resourceType string
+ data string
+ }
+ var resources []resourceRow
+ for rows.Next() {
+ var r resourceRow
+ if err := rows.Scan(&r.id, &r.resourceType, &r.data); err != nil {
+ rows.Close()
+ return fmt.Errorf("scanning resource: %w", err)
+ }
+ resources = append(resources, r)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return fmt.Errorf("reading resource rows: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("closing resource rows: %w", err)
+ }
+
+ for _, r := range resources {
+ if _, err := conn.ExecContext(ctx,
+ `INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (?, ?, ?, ?)`,
+ ftsRowID(r.resourceType, r.id), r.id, r.resourceType, r.data,
+ ); err != nil {
+ return fmt.Errorf("indexing resource %s/%s: %w", r.resourceType, r.id, err)
+ }
+ }
+ return nil
+}
+
const (
migrationLockTimeout = 30 * time.Second
migrationLockBackoffMin = 5 * time.Millisecond
@@ -468,14 +600,14 @@ func (s *Store) upsertGenericResourceTx(tx *sql.Tx, resourceType, id string, dat
_, 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`,
+ ON CONFLICT(resource_type, id) DO UPDATE SET data = excluded.data, synced_at = excluded.synced_at, updated_at = excluded.updated_at`,
id, resourceType, string(data), time.Now(), time.Now(),
)
if err != nil {
return err
}
- ftsRowid := ftsRowID(id)
+ ftsRowid := ftsRowID(resourceType, id)
// Use explicit rowid for FTS5 compatibility with modernc.org/sqlite.
// Standard DELETE WHERE column=? may not work on FTS5 virtual tables.
if _, err = tx.Exec(`DELETE FROM resources_fts WHERE rowid = ?`, ftsRowid); err != nil {
@@ -555,7 +687,7 @@ func (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {
}
rows, err := s.db.Query(
`SELECT r.data FROM resources r
- JOIN resources_fts f ON r.id = f.id
+ JOIN resources_fts f ON r.id = f.id AND r.resource_type = f.resource_type
WHERE resources_fts MATCH ?
ORDER BY rank
LIMIT ?`,
@@ -589,8 +721,12 @@ func extractObjectID(obj map[string]any) string {
// ftsRowID derives a deterministic rowid from a string ID for use with FTS5.
// modernc.org/sqlite's FTS5 implementation may not support DELETE WHERE column=?
// on virtual tables, so we use explicit rowids and DELETE WHERE rowid=? instead.
-func ftsRowID(id string) int64 {
+func ftsRowID(scope, id string) int64 {
var h uint64
+ for _, c := range scope {
+ h = h*31 + uint64(c)
+ }
+ h *= 31
for _, c := range id {
h = h*31 + uint64(c)
}
@@ -665,7 +801,7 @@ func (s *Store) upsert{{pascal .Name}}Tx(tx *sql.Tx, id string, obj map[string]a
// Standalone FTS: manually sync since content-sync triggers aren't used.
// Use explicit rowid for FTS5 compatibility with modernc.org/sqlite.
- ftsRowid := ftsRowID(id)
+ ftsRowid := ftsRowID("{{.Name}}", id)
_, _ = tx.Exec(`DELETE FROM {{safeNameSuffix .Name "_fts"}} WHERE rowid = ?`, ftsRowid)
_, _ = tx.Exec(
`INSERT INTO {{safeNameSuffix .Name "_fts"}} (rowid, id, {{safeJoin .FTS5Fields ", "}}) VALUES (?{{- range .FTS5Fields}}, ?{{- end}}, ?)`,
diff --git a/internal/generator/templates/store_schema_version_test.go.tmpl b/internal/generator/templates/store_schema_version_test.go.tmpl
index 9700a99b..10f54b80 100644
--- a/internal/generator/templates/store_schema_version_test.go.tmpl
+++ b/internal/generator/templates/store_schema_version_test.go.tmpl
@@ -39,7 +39,7 @@ func TestSchemaVersion_StampedOnFreshDB(t *testing.T) {
// TestSchemaVersion_StampExistingZeroDB verifies the stamp-and-continue
// rule for existing deployed databases. A DB that predates the gate has
// user_version = 0; opening it with this binary should stamp the version
-// to 1 without touching any data.
+// to StoreSchemaVersion without touching any data.
func TestSchemaVersion_StampExistingZeroDB(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "data.db")
@@ -254,6 +254,158 @@ func TestSchemaVersion_ReopenIsIdempotent(t *testing.T) {
}
}
+func TestResources_CompositeKeyPreservesOverlappingIDs(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer s.Close()
+
+ if err := s.Upsert("biz", "shared", []byte(`{"kind":"biz","name":"Pinky restaurant"}`)); err != nil {
+ t.Fatalf("upsert biz: %v", err)
+ }
+ if err := s.Upsert("bookmark", "shared", []byte(`{"kind":"bookmark","note":"anniversary"}`)); err != nil {
+ t.Fatalf("upsert bookmark: %v", err)
+ }
+
+ biz, err := s.Get("biz", "shared")
+ if err != nil {
+ t.Fatalf("get biz: %v", err)
+ }
+ if string(biz) != `{"kind":"biz","name":"Pinky restaurant"}` {
+ t.Fatalf("biz payload = %s", biz)
+ }
+
+ bookmark, err := s.Get("bookmark", "shared")
+ if err != nil {
+ t.Fatalf("get bookmark: %v", err)
+ }
+ if string(bookmark) != `{"kind":"bookmark","note":"anniversary"}` {
+ t.Fatalf("bookmark payload = %s", bookmark)
+ }
+
+ var count int
+ if err := s.DB().QueryRow(`SELECT COUNT(*) FROM resources WHERE id = 'shared'`).Scan(&count); err != nil {
+ t.Fatalf("count overlapping rows: %v", err)
+ }
+ if count != 2 {
+ t.Fatalf("overlapping row count = %d, want 2", count)
+ }
+
+ matches, err := s.Search("restaurant", 10)
+ if err != nil {
+ t.Fatalf("search restaurant: %v", err)
+ }
+ if len(matches) != 1 || string(matches[0]) != `{"kind":"biz","name":"Pinky restaurant"}` {
+ t.Fatalf("restaurant search = %q, want only biz payload", matches)
+ }
+}
+
+func TestMigrate_ResourcesCompositeKeyUpgrade(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "data.db")
+
+ raw, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ t.Fatalf("open raw: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE resources (
+ id TEXT PRIMARY KEY,
+ resource_type TEXT NOT NULL,
+ data JSON NOT NULL,
+ synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v1 resources: %v", err)
+ }
+ if _, err := raw.Exec(`CREATE VIRTUAL TABLE resources_fts USING fts5(
+ id, resource_type, content, tokenize='porter unicode61'
+ )`); err != nil {
+ raw.Close()
+ t.Fatalf("create v1 resources_fts: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources (id, resource_type, data) VALUES ('shared', 'biz', '{"kind":"biz","name":"legacy restaurant"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("insert v1 resource: %v", err)
+ }
+ if _, err := raw.Exec(`INSERT INTO resources_fts (rowid, id, resource_type, content) VALUES (1, 'shared', 'biz', '{"kind":"biz","name":"legacy restaurant"}')`); err != nil {
+ raw.Close()
+ t.Fatalf("insert v1 fts row: %v", err)
+ }
+ if _, err := raw.Exec(`PRAGMA user_version = 1`); err != nil {
+ raw.Close()
+ t.Fatalf("stamp v1: %v", err)
+ }
+ raw.Close()
+
+ s, err := Open(dbPath)
+ if err != nil {
+ t.Fatalf("open upgraded db: %v", err)
+ }
+ defer s.Close()
+
+ v, err := s.SchemaVersion()
+ if err != nil {
+ t.Fatalf("read schema version: %v", err)
+ }
+ if v != StoreSchemaVersion {
+ t.Fatalf("upgraded version = %d, want %d", v, StoreSchemaVersion)
+ }
+
+ rows, err := s.DB().Query(`PRAGMA table_info(resources)`)
+ if err != nil {
+ t.Fatalf("table_info resources: %v", err)
+ }
+ defer rows.Close()
+
+ pk := map[string]int{}
+ for rows.Next() {
+ var cid int
+ var name, typ string
+ var notnull, pkOrder int
+ var dflt sql.NullString
+ if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pkOrder); err != nil {
+ t.Fatalf("scan table_info: %v", err)
+ }
+ pk[name] = pkOrder
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("table_info rows: %v", err)
+ }
+ if pk["resource_type"] != 1 || pk["id"] != 2 {
+ t.Fatalf("resources primary key order = resource_type:%d id:%d, want resource_type:1 id:2", pk["resource_type"], pk["id"])
+ }
+
+ if err := s.Upsert("bookmark", "shared", []byte(`{"kind":"bookmark","note":"after upgrade"}`)); err != nil {
+ t.Fatalf("upsert overlapping resource after upgrade: %v", err)
+ }
+
+ biz, err := s.Get("biz", "shared")
+ if err != nil {
+ t.Fatalf("get migrated biz: %v", err)
+ }
+ if string(biz) != `{"kind":"biz","name":"legacy restaurant"}` {
+ t.Fatalf("migrated biz payload = %s", biz)
+ }
+
+ bookmark, err := s.Get("bookmark", "shared")
+ if err != nil {
+ t.Fatalf("get upgraded bookmark: %v", err)
+ }
+ if string(bookmark) != `{"kind":"bookmark","note":"after upgrade"}` {
+ t.Fatalf("upgraded bookmark payload = %s", bookmark)
+ }
+
+ matches, err := s.Search("legacy", 10)
+ if err != nil {
+ t.Fatalf("search migrated fts: %v", err)
+ }
+ if len(matches) != 1 || string(matches[0]) != `{"kind":"biz","name":"legacy restaurant"}` {
+ t.Fatalf("legacy search = %q, want migrated biz payload", matches)
+ }
+}
+
// TestOpenReadOnly_RejectsWrites pins the contract: direct and CTE-wrapped
// writes against the main DB fail under mode=ro. Deliberately does not
// assert VACUUM INTO and ATTACH DATABASE — modernc.org/sqlite allows both
← 48cc2a31 fix(cli): emit form-encoded request bodies (#947)
·
back to Cli Printing Press
·
feat(cli): emit nested-object body fields as parent-prefixed ebc8cd81 →