Stable Asura series IDs: strip rotating build hash (#6)
Asura series slugs carry a site-wide build hash (-059befe1) that rotates on every redeploy, silently orphaning all asura bookmarks (old-hash URLs 302 to new-hash ones, so detect() yields keys that never match stored rows).
Backend:
- migrateAsuraKeys in OpenStore: one-off idempotent migration rewriting hashed asura keys to the stable hashless ID, merging collisions to newest updated_at; losers deleted before winner rewrite (PK-collision safe) — regression tests included
- poller latestChapterFrom: build hash made optional in chapter-scoping regex so latest_chapter survives redeploys
Userscript:
- stripBuildHash(/-[0-9a-f]{8}$/) applied to seriesId in both asura detect branches; URLs keep full slug (stale hashes 302)
- load-time migration rewrites cached + retry-queued asura keys to the stripped form so a queued PUT cannot resurrect an orphaned row
Docs: AGENTS.md + CLAUDE.md URL-shape notes; design spec at docs/superpowers/specs/2026-07-28-asura-stable-series-id-design.md
Verified: go test -count=1 ./... green (new rotation + collision tests), node --check green, regex verified against live asurascans.com slugs.
Deploy order: backend first (migration runs at OpenStore). Orphan rows created by not-yet-updated userscripts self-heal on next backend restart.
Reviewed-on: #6
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #6.
This commit is contained in:
@@ -905,3 +905,132 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||
t.Fatal("due included a finished series")
|
||||
}
|
||||
}
|
||||
|
||||
// Asura slugs used to include the site build hash; rows keyed with it must
|
||||
// be rewritten to the stable ID on open, merging hash-generations of the
|
||||
// same series into the newest row.
|
||||
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
||||
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
seed := []Bookmark{
|
||||
{Key: "asura:swordmasters-youngest-son-f886a8af", Site: "asura",
|
||||
SeriesID: "swordmasters-youngest-son-f886a8af", Title: "Old gen",
|
||||
LastChapterNum: 50, UpdatedAt: 100},
|
||||
{Key: "asura:swordmasters-youngest-son-059befe1", Site: "asura",
|
||||
SeriesID: "swordmasters-youngest-son-059befe1", Title: "Re-bookmarked",
|
||||
LastChapterNum: 60, UpdatedAt: 200},
|
||||
{Key: "asura:overgeared-059befe1", Site: "asura",
|
||||
SeriesID: "overgeared-059befe1", Title: "Single gen", UpdatedAt: 150},
|
||||
// Hash-like suffix on another site must be left alone.
|
||||
{Key: "demonic:x-deadbeef", Site: "demonic",
|
||||
SeriesID: "x-deadbeef", Title: "Not asura", UpdatedAt: 300},
|
||||
}
|
||||
for _, b := range seed {
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { reopened.Close() })
|
||||
|
||||
list, err := reopened.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
byKey := map[string]Bookmark{}
|
||||
for _, b := range list {
|
||||
byKey[b.Key] = b
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("want 3 rows after merge, got %d: %+v", len(list), list)
|
||||
}
|
||||
merged, ok := byKey["asura:swordmasters-youngest-son"]
|
||||
if !ok {
|
||||
t.Fatalf("merged key missing: %+v", byKey)
|
||||
}
|
||||
// Newest row wins the merge.
|
||||
if merged.Title != "Re-bookmarked" || merged.LastChapterNum != 60 || merged.UpdatedAt != 200 {
|
||||
t.Fatalf("merge kept wrong row: %+v", merged)
|
||||
}
|
||||
if merged.SeriesID != "swordmasters-youngest-son" {
|
||||
t.Fatalf("series_id not stripped: %q", merged.SeriesID)
|
||||
}
|
||||
if _, ok := byKey["asura:overgeared-059befe1"]; ok {
|
||||
t.Fatal("single-generation hashed key not rewritten")
|
||||
}
|
||||
if _, ok := byKey["asura:overgeared"]; !ok {
|
||||
t.Fatal("single-generation row missing under stripped key")
|
||||
}
|
||||
if _, ok := byKey["demonic:x-deadbeef"]; !ok {
|
||||
t.Fatal("non-asura row touched")
|
||||
}
|
||||
|
||||
// Idempotent: a third open changes nothing.
|
||||
third, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("third open: %v", err)
|
||||
}
|
||||
third.Close()
|
||||
}
|
||||
|
||||
// A hashed row and a pre-existing hashless row of the same series collide on
|
||||
// the stripped key. The winner rewrite must happen only after the loser is
|
||||
// gone, or the UPDATE hits a primary-key collision and OpenStore fails.
|
||||
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
||||
|
||||
store, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
seed := []Bookmark{
|
||||
{Key: "asura:overgeared", Site: "asura", SeriesID: "overgeared",
|
||||
Title: "Hashless", LastChapterNum: 10, UpdatedAt: 100},
|
||||
{Key: "asura:overgeared-059befe1", Site: "asura",
|
||||
SeriesID: "overgeared-059befe1", Title: "Hashed newer",
|
||||
LastChapterNum: 20, UpdatedAt: 200},
|
||||
}
|
||||
for _, b := range seed {
|
||||
if _, err := store.Upsert(b); err != nil {
|
||||
t.Fatalf("seed %s: %v", b.Key, err)
|
||||
}
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := OpenStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { reopened.Close() })
|
||||
|
||||
list, err := reopened.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("want 1 row after merge, got %d: %+v", len(list), list)
|
||||
}
|
||||
merged := list[0]
|
||||
if merged.Key != "asura:overgeared" {
|
||||
t.Fatalf("merged key = %q, want asura:overgeared", merged.Key)
|
||||
}
|
||||
if merged.SeriesID != "overgeared" {
|
||||
t.Fatalf("series_id = %q, want overgeared", merged.SeriesID)
|
||||
}
|
||||
if merged.Title != "Hashed newer" || merged.LastChapterNum != 20 || merged.UpdatedAt != 200 {
|
||||
t.Fatalf("merge kept wrong row: %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user