[object Object]

← 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

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, &notnull, &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, &notnull, &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 →