fix: address code review findings across backend and frontend
CI / test (pull_request) Successful in 2m13s
CI / test (pull_request) Successful in 2m13s
Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader. Move PRAGMA foreign_keys outside transaction in v3 migration where SQLite was silently ignoring it. Escape LIKE wildcards in link resolution to prevent false matches. Add non-localhost binding warning, log writeJSON encoder errors, add ?permanent=true for explicit hard delete, preserve title/description during absorb, use millisecond backup timestamps, add path.Clean to spaHandler. Frontend gains checkedJSON() for resp.ok validation, consistent stopPropagation, and shared renderCardSections() to eliminate duplicate rendering.
This commit is contained in:
@@ -272,6 +272,20 @@ type DeleteResponse struct {
|
||||
func deleteEntity(store *db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if r.URL.Query().Get("permanent") == "true" {
|
||||
if err := store.HardDelete(r.Context(), id); err != nil {
|
||||
if err == db.ErrNotFound {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, DeleteResponse{Result: "hard"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := store.SoftDelete(r.Context(), id)
|
||||
if err != nil {
|
||||
if err == db.ErrNotFound {
|
||||
|
||||
@@ -38,7 +38,9 @@ type EntityResponse struct {
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("writeJSON encode error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||
|
||||
@@ -47,7 +47,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
|
||||
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
p := path.Clean(r.URL.Path)
|
||||
if p == "/" || path.Ext(p) == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(indexHTML)
|
||||
|
||||
+12
-10
@@ -56,8 +56,6 @@ func (s *Store) Backup(dst string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const currentSchema = 5
|
||||
|
||||
var migrations = []func(db *sql.DB) error{
|
||||
// v1: initial schema
|
||||
func(db *sql.DB) error {
|
||||
@@ -108,17 +106,18 @@ var migrations = []func(db *sql.DB) error{
|
||||
|
||||
// v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder')
|
||||
func(db *sql.DB) error {
|
||||
// PRAGMA foreign_keys must be set outside a transaction (SQLite ignores it inside one)
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return fmt.Errorf("migrate fk off: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
return fmt.Errorf("migrate rename: %w", err)
|
||||
}
|
||||
@@ -169,11 +168,14 @@ var migrations = []func(db *sql.DB) error{
|
||||
return fmt.Errorf("migrate tags drop: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
db.Exec(`PRAGMA foreign_keys = ON`)
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
||||
return fmt.Errorf("migrate fk on: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
return nil
|
||||
},
|
||||
|
||||
// v4: add indexes for common query filters
|
||||
|
||||
+24
-2
@@ -464,6 +464,18 @@ func (s *Store) SoftDelete(ctx context.Context, id string) (DeleteResult, error)
|
||||
return DeletedSoft, err
|
||||
}
|
||||
|
||||
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, "DELETE FROM entities WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
|
||||
target, err := s.Get(ctx, targetID)
|
||||
if err != nil {
|
||||
@@ -487,8 +499,18 @@ func (s *Store) Absorb(ctx context.Context, targetID, sourceID string) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
merged := target.Body + "\n" + source.Body
|
||||
|
||||
if _, err := tx.ExecContext(ctx, "UPDATE entities SET body = ?, modified_at = ? WHERE id = ?",
|
||||
merged, now, targetID); err != nil {
|
||||
title := target.Title
|
||||
if title == nil {
|
||||
title = source.Title
|
||||
}
|
||||
desc := target.Description
|
||||
if desc == nil {
|
||||
desc = source.Description
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
"UPDATE entities SET body = ?, title = ?, description = ?, modified_at = ? WHERE id = ?",
|
||||
merged, title, desc, now, targetID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ type Backlink struct {
|
||||
LinkText string
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
|
||||
lower := strings.ToLower(linkText)
|
||||
|
||||
@@ -29,8 +34,8 @@ func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, ex
|
||||
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT id FROM entities
|
||||
WHERE LOWER(body) LIKE ? AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`, "%"+lower+"%", excludeID).Scan(&id)
|
||||
WHERE LOWER(body) LIKE ? ESCAPE '\' AND id != ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`, "%"+escapeLike(lower)+"%", excludeID).Scan(&id)
|
||||
if err == nil {
|
||||
return &id
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
entropy *ulid.MonotonicEntropy
|
||||
entropy *ulid.LockedMonotonicReader
|
||||
entropyOnce sync.Once
|
||||
)
|
||||
|
||||
func New() string {
|
||||
entropyOnce.Do(func() {
|
||||
entropy = ulid.Monotonic(rand.Reader, 0)
|
||||
entropy = &ulid.LockedMonotonicReader{
|
||||
MonotonicReader: ulid.Monotonic(rand.Reader, 0),
|
||||
}
|
||||
})
|
||||
return ulid.MustNew(ulid.Now(), entropy).String()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ulid
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -26,3 +27,25 @@ func TestNew_Sortable(t *testing.T) {
|
||||
t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_ConcurrentUnique(t *testing.T) {
|
||||
const n = 100
|
||||
ids := make([]string, n)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
ids[idx] = New()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
seen := make(map[string]struct{}, n)
|
||||
for _, id := range ids {
|
||||
if _, dup := seen[id]; dup {
|
||||
t.Fatalf("duplicate ULID under concurrency: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user