fix: harden API, DB schema, and CLI safety
- Add 'reminder' to glyph CHECK constraint (was accepted by parser but
rejected by DB)
- Default serve bind to 127.0.0.1, add --host flag for LAN access
- Validate card_data as JSON in Store.Create/Update/Promote
- Return pagination envelope {data,total,limit,offset} from list endpoint
- Append absorb breadcrumb to source entity before soft-delete
- Add Levenshtein fuzzy match to catch command typos before routing to add
- Replace DDL string-matching migrations with versioned schema_version table
- Update web UI and API tests for envelope response format
This commit is contained in:
+55
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -26,6 +27,10 @@ func Execute() error {
|
|||||||
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
||||||
if first != "help" && first != "completion" &&
|
if first != "help" && first != "completion" &&
|
||||||
!isFlag && !isSubcommand(first) {
|
!isFlag && !isSubcommand(first) {
|
||||||
|
if near := nearSubcommand(first); near != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command %q — did you mean %q?\n", first, near)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
||||||
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,56 @@ func isSubcommand(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nearSubcommand(name string) string {
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if d := editDist(name, c.Name()); d > 0 && d <= 2 {
|
||||||
|
return c.Name()
|
||||||
|
}
|
||||||
|
for _, alias := range c.Aliases {
|
||||||
|
if d := editDist(name, alias); d > 0 && d <= 2 {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func editDist(a, b string) int {
|
||||||
|
la, lb := len(a), len(b)
|
||||||
|
if la == 0 {
|
||||||
|
return lb
|
||||||
|
}
|
||||||
|
if lb == 0 {
|
||||||
|
return la
|
||||||
|
}
|
||||||
|
prev := make([]int, lb+1)
|
||||||
|
for j := range prev {
|
||||||
|
prev[j] = j
|
||||||
|
}
|
||||||
|
for i := 1; i <= la; i++ {
|
||||||
|
curr := make([]int, lb+1)
|
||||||
|
curr[0] = i
|
||||||
|
for j := 1; j <= lb; j++ {
|
||||||
|
cost := 1
|
||||||
|
if a[i-1] == b[j-1] {
|
||||||
|
cost = 0
|
||||||
|
}
|
||||||
|
ins := curr[j-1] + 1
|
||||||
|
del := prev[j] + 1
|
||||||
|
sub := prev[j-1] + cost
|
||||||
|
curr[j] = ins
|
||||||
|
if del < curr[j] {
|
||||||
|
curr[j] = del
|
||||||
|
}
|
||||||
|
if sub < curr[j] {
|
||||||
|
curr[j] = sub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = curr
|
||||||
|
}
|
||||||
|
return prev[lb]
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(addCmd)
|
rootCmd.AddCommand(addCmd)
|
||||||
rootCmd.AddCommand(lsCmd)
|
rootCmd.AddCommand(lsCmd)
|
||||||
|
|||||||
+5
-3
@@ -19,6 +19,7 @@ var WebFS fs.FS
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
servePort int
|
servePort int
|
||||||
|
serveHost string
|
||||||
serveDev bool
|
serveDev bool
|
||||||
tlsCert string
|
tlsCert string
|
||||||
tlsKey string
|
tlsKey string
|
||||||
@@ -32,6 +33,7 @@ var serveCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
|
serveCmd.Flags().IntVar(&servePort, "port", 0, "port to listen on (default 4444, or 4443 with TLS)")
|
||||||
|
serveCmd.Flags().StringVar(&serveHost, "host", "127.0.0.1", "address to bind to (default localhost only)")
|
||||||
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
|
serveCmd.Flags().BoolVar(&serveDev, "dev", false, "enable CORS for development")
|
||||||
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
|
serveCmd.Flags().StringVar(&tlsCert, "tls-cert", "", "path to TLS certificate file")
|
||||||
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
|
serveCmd.Flags().StringVar(&tlsKey, "tls-key", "", "path to TLS private key file")
|
||||||
@@ -70,7 +72,7 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
router = api.NewRouter(store, serveDev, WebFS)
|
router = api.NewRouter(store, serveDev, WebFS)
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf("%s:%d", serveHost, port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: router,
|
Handler: router,
|
||||||
@@ -81,9 +83,9 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if useTLS {
|
if useTLS {
|
||||||
fmt.Printf("nib serving on https://localhost%s\n", addr)
|
fmt.Printf("nib serving on https://%s\n", addr)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("nib serving on http://localhost%s\n", addr)
|
fmt.Printf("nib serving on http://%s\n", addr)
|
||||||
}
|
}
|
||||||
if serveDev {
|
if serveDev {
|
||||||
fmt.Println(" CORS enabled (dev mode)")
|
fmt.Println(" CORS enabled (dev mode)")
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ go 1.24.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4
|
github.com/atotto/clipboard v0.1.4
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
@@ -12,10 +15,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
|||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
|||||||
+21
-14
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
|
|||||||
return srv, store
|
return srv, store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type listEnvelope struct {
|
||||||
|
Data []EntityResponse `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
|
||||||
|
t.Helper()
|
||||||
|
var env listEnvelope
|
||||||
|
json.NewDecoder(resp.Body).Decode(&env)
|
||||||
|
return env.Data
|
||||||
|
}
|
||||||
|
|
||||||
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := json.Marshal(body)
|
b, err := json.Marshal(body)
|
||||||
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 2 {
|
if len(entities) != 2 {
|
||||||
t.Fatalf("expected 2, got %d", len(entities))
|
t.Fatalf("expected 2, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1, got %d", len(entities))
|
t.Fatalf("expected 1, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1 card, got %d", len(entities))
|
t.Fatalf("expected 1 card, got %d", len(entities))
|
||||||
}
|
}
|
||||||
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var page1 []EntityResponse
|
page1 := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&page1)
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var page2 []EntityResponse
|
page2 := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&page2)
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(page1) != 2 || len(page2) != 2 {
|
if len(page1) != 2 || len(page2) != 2 {
|
||||||
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, listResp)
|
||||||
json.NewDecoder(listResp.Body).Decode(&entities)
|
|
||||||
listResp.Body.Close()
|
listResp.Body.Close()
|
||||||
for _, ent := range entities {
|
for _, ent := range entities {
|
||||||
if ent.ID == source.ID {
|
if ent.ID == source.ID {
|
||||||
@@ -686,8 +694,7 @@ func TestListEntities_TitleInResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
var entities []EntityResponse
|
entities := decodeList(t, resp)
|
||||||
json.NewDecoder(resp.Body).Decode(&entities)
|
|
||||||
if len(entities) != 1 {
|
if len(entities) != 1 {
|
||||||
t.Fatalf("expected 1, got %d", len(entities))
|
t.Fatalf("expected 1, got %d", len(entities))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
p.Offset = offset
|
p.Offset = offset
|
||||||
}
|
}
|
||||||
|
if p.Limit <= 0 {
|
||||||
|
p.Limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := store.Count(p)
|
||||||
|
if err != nil {
|
||||||
|
writeInternalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
entities, err := store.List(p)
|
entities, err := store.List(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := make([]EntityResponse, len(entities))
|
items := make([]EntityResponse, len(entities))
|
||||||
for i, e := range entities {
|
for i, e := range entities {
|
||||||
resp[i] = entityToResponse(e)
|
items[i] = entityToResponse(e)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"data": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": p.Limit,
|
||||||
|
"offset": p.Offset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +175,10 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := store.Create(e); err != nil {
|
if err := store.Create(e); err != nil {
|
||||||
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err == db.ErrInvalidCardData {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeInternalError(w, err)
|
writeInternalError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-27
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -16,6 +15,7 @@ var (
|
|||||||
ErrAlreadyPromoted = errors.New("invalid_promote")
|
ErrAlreadyPromoted = errors.New("invalid_promote")
|
||||||
ErrAlreadyFluid = errors.New("invalid_demote")
|
ErrAlreadyFluid = errors.New("invalid_demote")
|
||||||
ErrTargetCrystallized = errors.New("invalid_absorb")
|
ErrTargetCrystallized = errors.New("invalid_absorb")
|
||||||
|
ErrInvalidCardData = errors.New("invalid_card_data")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@@ -51,22 +51,23 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
const currentSchema = 3
|
||||||
_, err := s.db.Exec(`
|
|
||||||
|
var migrations = []func(db *sql.DB) error{
|
||||||
|
// v1: initial schema
|
||||||
|
func(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS entities (
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
modified_at TEXT NOT NULL,
|
modified_at TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
glyph TEXT NOT NULL
|
glyph TEXT NOT NULL,
|
||||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
|
||||||
time_anchor TEXT,
|
time_anchor TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
card_type TEXT
|
card_type TEXT,
|
||||||
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note')
|
|
||||||
OR card_type IS NULL),
|
|
||||||
card_data TEXT,
|
card_data TEXT,
|
||||||
use_count INTEGER NOT NULL DEFAULT 0,
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
last_used_at TEXT
|
last_used_at TEXT
|
||||||
@@ -86,29 +87,29 @@ func (s *Store) migrate() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
||||||
ON entity_tags(tag);
|
ON entity_tags(tag);
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
},
|
||||||
|
|
||||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
|
// v2: add title and description columns
|
||||||
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
|
func(db *sql.DB) error {
|
||||||
|
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
|
||||||
|
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
|
||||||
// Migrate CHECK constraint to include 'note' card type
|
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||||
var needsMigrate bool
|
func(db *sql.DB) error {
|
||||||
row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`)
|
tx, err := db.Begin()
|
||||||
var ddl string
|
|
||||||
if row.Scan(&ddl) == nil {
|
|
||||||
hasNote := strings.Contains(ddl, "'link', 'note'")
|
|
||||||
hasModified := strings.Contains(ddl, "modified_at")
|
|
||||||
needsMigrate = !hasNote || !hasModified
|
|
||||||
}
|
|
||||||
if needsMigrate {
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Disable FK checks during rebuild to avoid dangling references
|
||||||
|
if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk off: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil {
|
||||||
return fmt.Errorf("migrate rename: %w", err)
|
return fmt.Errorf("migrate rename: %w", err)
|
||||||
}
|
}
|
||||||
@@ -118,7 +119,7 @@ func (s *Store) migrate() error {
|
|||||||
modified_at TEXT NOT NULL,
|
modified_at TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
glyph TEXT NOT NULL
|
glyph TEXT NOT NULL
|
||||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
|
||||||
time_anchor TEXT,
|
time_anchor TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -140,12 +141,54 @@ func (s *Store) migrate() error {
|
|||||||
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
|
if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil {
|
||||||
return fmt.Errorf("migrate drop: %w", err)
|
return fmt.Errorf("migrate drop: %w", err)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("migrate commit: %w", err)
|
// Rebuild entity_tags to point FK at new entities table
|
||||||
|
if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags rename: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`CREATE TABLE entity_tags (
|
||||||
|
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (entity_id, tag)
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags create: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags copy: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil {
|
||||||
|
return fmt.Errorf("migrate tags drop: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||||
|
return fmt.Errorf("migrate fk on: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`)
|
||||||
|
|
||||||
|
var version int
|
||||||
|
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
version = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := version; i < len(migrations); i++ {
|
||||||
|
if err := migrations[i](s.db); err != nil {
|
||||||
|
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
if version == 0 {
|
||||||
|
_, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations))
|
||||||
|
} else if len(migrations) > version {
|
||||||
|
_, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations))
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultPath() (string, error) {
|
func DefaultPath() (string, error) {
|
||||||
|
|||||||
+28
-5
@@ -104,6 +104,9 @@ type EntityUpdate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Create(e *Entity) error {
|
func (s *Store) Create(e *Entity) error {
|
||||||
|
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
e.ID = nibulid.New()
|
e.ID = nibulid.New()
|
||||||
e.CreatedAt = now
|
e.CreatedAt = now
|
||||||
@@ -179,7 +182,7 @@ func (s *Store) Get(id string) (*Entity, error) {
|
|||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
func listWhere(params ListParams) (string, []any) {
|
||||||
var where []string
|
var where []string
|
||||||
var args []any
|
var args []any
|
||||||
|
|
||||||
@@ -214,10 +217,23 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
args = append(args, string(*params.CardTypeFilter))
|
args = append(args, string(*params.CardTypeFilter))
|
||||||
}
|
}
|
||||||
|
|
||||||
whereClause := ""
|
clause := ""
|
||||||
if len(where) > 0 {
|
if len(where) > 0 {
|
||||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
clause = "WHERE " + strings.Join(where, " AND ")
|
||||||
}
|
}
|
||||||
|
return clause, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Count(params ListParams) (int, error) {
|
||||||
|
whereClause, args := listWhere(params)
|
||||||
|
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(query, args...).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||||
|
whereClause, args := listWhere(params)
|
||||||
|
|
||||||
orderCol := "e.created_at"
|
orderCol := "e.created_at"
|
||||||
switch params.Sort {
|
switch params.Sort {
|
||||||
@@ -336,6 +352,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
|||||||
args = append(args, string(*u.CardType))
|
args = append(args, string(*u.CardType))
|
||||||
}
|
}
|
||||||
if u.CardData != nil {
|
if u.CardData != nil {
|
||||||
|
if !json.Valid([]byte(*u.CardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
sets = append(sets, "card_data = ?")
|
sets = append(sets, "card_data = ?")
|
||||||
args = append(args, *u.CardData)
|
args = append(args, *u.CardData)
|
||||||
}
|
}
|
||||||
@@ -370,6 +389,9 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
|||||||
|
|
||||||
dataVal := "{}"
|
dataVal := "{}"
|
||||||
if cardData != nil {
|
if cardData != nil {
|
||||||
|
if !json.Valid([]byte(*cardData)) {
|
||||||
|
return ErrInvalidCardData
|
||||||
|
}
|
||||||
dataVal = *cardData
|
dataVal = *cardData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,8 +495,9 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
|
||||||
now, sourceID); err != nil {
|
if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
|
||||||
|
absorbNote, now, now, sourceID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1247,9 +1247,9 @@
|
|||||||
|
|
||||||
async function loadEntities() {
|
async function loadEntities() {
|
||||||
const params = buildListParams(0);
|
const params = buildListParams(0);
|
||||||
const results = await api.listEntities(params);
|
const resp = await api.listEntities(params);
|
||||||
state.entities = results;
|
state.entities = resp.data;
|
||||||
state.hasMore = results.length === PAGE_SIZE;
|
state.hasMore = (resp.offset + resp.data.length) < resp.total;
|
||||||
state.selectedIndex = -1;
|
state.selectedIndex = -1;
|
||||||
renderEntityList();
|
renderEntityList();
|
||||||
renderDetailPane();
|
renderDetailPane();
|
||||||
@@ -1258,9 +1258,9 @@
|
|||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
const params = buildListParams(state.entities.length);
|
const params = buildListParams(state.entities.length);
|
||||||
const results = await api.listEntities(params);
|
const resp = await api.listEntities(params);
|
||||||
state.entities = state.entities.concat(results);
|
state.entities = state.entities.concat(resp.data);
|
||||||
state.hasMore = results.length === PAGE_SIZE;
|
state.hasMore = (resp.offset + resp.data.length) < resp.total;
|
||||||
renderEntityList();
|
renderEntityList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user