From b903d14fc2a6fcc448df8b178c0d7e161e8a7a3d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 4 Sep 2026 11:37:29 +0700 Subject: [PATCH] chore(sticker): drop the retired per-user pack records at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module stores nothing: /addsticker takes its pack from STICKER_PACK_NAME and the set owner from OWNER_ID, and the factory ignores the collection it is handed. Everything still in the sticker collection is therefore unreachable by any code path — pack documents keyed by owner ID, "slug:" name reservations and "pending-delete:" confirmations, all orphaned when the per-user commands were removed. InitStore lists and deletes them once per database, guarded by a systemstate marker in the same shape as the stock and stats migrations. It aborts without writing the marker so a partial run retries on the next boot, and deletes are idempotent. A collection that is already empty is the normal case on a fresh deploy and on the memory backend. This permanently removes data. Back up the sticker collection before the first deploy that carries it. --- cmd/server/main.go | 17 +++ docs/sticker-packs.md | 9 ++ internal/modules/sticker/startup.go | 90 ++++++++++++++ internal/modules/sticker/startup_test.go | 146 +++++++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 internal/modules/sticker/startup.go create mode 100644 internal/modules/sticker/startup_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 6a756b6..bf0a9b4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -132,6 +132,9 @@ func main() { if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { log.Fatal("lol storage init failed", "err", err) } + if err := initStickerStore(rootCtx, provider); err != nil { + log.Fatal("sticker storage init failed", "err", err) + } migrationCtx, cancelMigration := context.WithTimeout(rootCtx, stockMigrationTimeout) if err := initStockStore(migrationCtx, provider); err != nil { cancelMigration() @@ -246,6 +249,20 @@ func initStatsStoreWith(ctx context.Context, provider storage.Provider, init sta ) } +func initStickerStore(ctx context.Context, provider storage.Provider) error { + return initStickerStoreWith(ctx, provider, sticker.InitStore) +} + +type stickerStoreInitializer func(context.Context, storage.Collection, storage.Collection) error + +func initStickerStoreWith(ctx context.Context, provider storage.Provider, init stickerStoreInitializer) error { + return init( + ctx, + provider.Collection(sticker.CollectionName), + provider.Collection(systemstate.CollectionName), + ) +} + type stockStoreInitializer func(context.Context, storage.Collection, storage.Collection) error func initStockStoreWith(ctx context.Context, provider storage.Provider, init stockStoreInitializer) error { diff --git a/docs/sticker-packs.md b/docs/sticker-packs.md index 691f36c..33bc84f 100644 --- a/docs/sticker-packs.md +++ b/docs/sticker-packs.md @@ -4,6 +4,15 @@ bot contributes to. It is the whole of the `sticker` module — one command, no storage, and no per-user packs. +**The module stores nothing.** Its factory ignores the collection it is handed: +the pack comes from the environment and the set owner from `OWNER_ID`, so there +is nothing per-user to key. A one-time startup cleanup +(`migration:sticker-drop-legacy-packs-v1`) removes the records the retired +per-user pack commands left in the `sticker` collection — pack documents keyed +by owner ID, `slug:` name reservations, and `pending-delete:` confirmations. +It is marker-guarded, so it scans once per database and never touches anything +written afterwards. + | Command | Parameters | Reply to | What it does | |---|---|---|---| | `/addsticker` | `[emoji...]` | sticker, photo, or image document | Adds it to the shared pack and replies with the link | diff --git a/internal/modules/sticker/startup.go b/internal/modules/sticker/startup.go new file mode 100644 index 0000000..e7215b7 --- /dev/null +++ b/internal/modules/sticker/startup.go @@ -0,0 +1,90 @@ +package sticker + +import ( + "context" + "fmt" + "time" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" +) + +const legacyPackCleanupMarkerKey = "migration:sticker-drop-legacy-packs-v1" + +// legacyRecord is a placeholder type, not a schema. +// +// The retired design wrote three different shapes into this collection — pack +// records keyed by owner ID, "slug:" name reservations, and "pending-delete:" +// confirmations. Cleanup only lists keys and deletes them, and neither +// operation decodes a document, so one empty type serves for all three rather +// than resurrecting structs whose only remaining purpose would be deletion. +type legacyRecord struct{} + +// InitStore removes the per-user sticker pack records the retired pack +// commands left behind. +// +// The module no longer stores anything: /addsticker writes to one shared, +// env-configured set and takes the set owner's user ID from OWNER_ID, so there +// is nothing per-user to key. Its factory ignores the collection entirely. +// These documents are therefore unreachable by any code path — not stale data +// that some handler might still read, but orphans. +// +// Guarded by a completion marker so the scan runs once per database rather than +// on every boot, matching the stock and stats migrations. Safe to run against a +// collection that is already empty, and safe on the memory backend where the +// collection never had anything in it. +func InitStore(ctx context.Context, stickerColl, systemColl storage.Collection) error { + system := systemstate.New(systemColl) + marker, exists, err := system.Get(ctx, legacyPackCleanupMarkerKey) + if err != nil { + return fmt.Errorf("sticker legacy pack cleanup: read marker: %w", err) + } + if exists && marker.Status == "completed" { + return nil + } + + docs := storage.Typed[legacyRecord](stickerColl) + // Empty prefix: the retired design used three disjoint key spaces (bare + // owner IDs, "slug:", "pending-delete:") and all of them are dead, so + // listing everything is both correct and cheaper than three scans. + keys, err := docs.List(ctx, "") + if err != nil { + return fmt.Errorf("sticker legacy pack cleanup: list records: %w", err) + } + + var deleted int64 + for _, key := range keys { + if err := docs.Delete(ctx, key); err != nil { + // Abort without writing the marker, so the next boot retries the + // rest. Deletes are idempotent, so a partial run is safe to repeat. + return fmt.Errorf("sticker legacy pack cleanup: delete %s: %w", key, err) + } + deleted++ + } + if deleted > 0 { + log.Info("sticker legacy pack records removed", "count", deleted) + } + + marker = completedLegacyPackCleanup(marker, exists, deleted, time.Now().UnixMilli()) + if err := system.Put(ctx, legacyPackCleanupMarkerKey, marker); err != nil { + return fmt.Errorf("sticker legacy pack cleanup: write marker: %w", err) + } + return nil +} + +func completedLegacyPackCleanup(marker systemstate.Record, exists bool, deleted, now int64) systemstate.Record { + if !exists { + marker = systemstate.Record{ + Kind: "migration", + Name: "sticker drop legacy packs v1", + } + } + if marker.CompletedAt == 0 { + marker.CompletedAt = now + } + marker.Status = "completed" + marker.Count += deleted + marker.UpdatedAt = now + return marker +} diff --git a/internal/modules/sticker/startup_test.go b/internal/modules/sticker/startup_test.go new file mode 100644 index 0000000..e706b55 --- /dev/null +++ b/internal/modules/sticker/startup_test.go @@ -0,0 +1,146 @@ +package sticker + +import ( + "context" + "testing" + + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" +) + +// legacyShape stands in for the retired records: a pack keyed by owner ID, a +// "slug:" reservation, and a "pending-delete:" confirmation. Only the keys +// matter to the cleanup, so one loose shape covers all three. +type legacyShape struct { + Slug string `bson:"slug"` + OwnerID int64 `bson:"ownerId"` +} + +func seedLegacy(t *testing.T, coll storage.Collection, keys ...string) { + t.Helper() + docs := storage.Typed[legacyShape](coll) + for _, k := range keys { + if err := docs.Put(context.Background(), k, legacyShape{Slug: "old", OwnerID: 42}); err != nil { + t.Fatalf("seed %s: %v", k, err) + } + } +} + +func remainingKeys(t *testing.T, coll storage.Collection) []string { + t.Helper() + keys, err := storage.Typed[legacyShape](coll).List(context.Background(), "") + if err != nil { + t.Fatalf("list: %v", err) + } + return keys +} + +// All three retired key spaces go, in one pass. +func TestInitStore_RemovesEveryLegacyKeySpace(t *testing.T) { + provider := storage.NewMemoryProvider() + stickerColl := provider.Collection(CollectionName) + systemColl := provider.Collection(systemstate.CollectionName) + + seedLegacy(t, stickerColl, + "123456789", // pack record, keyed by owner ID + "987654321", // another owner's pack + "slug:mypack", // name reservation + "pending-delete:1234567", // /delpack confirmation + ) + + if err := InitStore(context.Background(), stickerColl, systemColl); err != nil { + t.Fatalf("InitStore: %v", err) + } + + if got := remainingKeys(t, stickerColl); len(got) != 0 { + t.Errorf("collection still holds %v, want it emptied", got) + } +} + +// The marker records how many were removed, so the count is auditable after +// the fact. +func TestInitStore_MarksCompletionWithCount(t *testing.T) { + provider := storage.NewMemoryProvider() + stickerColl := provider.Collection(CollectionName) + systemColl := provider.Collection(systemstate.CollectionName) + seedLegacy(t, stickerColl, "1", "2", "slug:x") + + if err := InitStore(context.Background(), stickerColl, systemColl); err != nil { + t.Fatalf("InitStore: %v", err) + } + + rec, found, err := systemstate.New(systemColl).Get(context.Background(), legacyPackCleanupMarkerKey) + if err != nil || !found { + t.Fatalf("marker: found=%v err=%v", found, err) + } + if rec.Status != "completed" { + t.Errorf("status = %q, want completed", rec.Status) + } + if rec.Count != 3 { + t.Errorf("count = %d, want 3", rec.Count) + } + if rec.CompletedAt == 0 || rec.UpdatedAt == 0 { + t.Errorf("timestamps unset: %+v", rec) + } +} + +// Once marked, the scan must not run again — and specifically must not delete +// anything written to this collection later. +func TestInitStore_MarkerStopsASecondPass(t *testing.T) { + provider := storage.NewMemoryProvider() + stickerColl := provider.Collection(CollectionName) + systemColl := provider.Collection(systemstate.CollectionName) + seedLegacy(t, stickerColl, "1") + + if err := InitStore(context.Background(), stickerColl, systemColl); err != nil { + t.Fatalf("first InitStore: %v", err) + } + + // Whatever a future version of this module might store. + seedLegacy(t, stickerColl, "something-new") + + if err := InitStore(context.Background(), stickerColl, systemColl); err != nil { + t.Fatalf("second InitStore: %v", err) + } + got := remainingKeys(t, stickerColl) + if len(got) != 1 || got[0] != "something-new" { + t.Errorf("remaining = %v, want only the newly written key", got) + } +} + +// An already-clean database is the normal case on a fresh deploy, and on the +// memory backend where the collection never held anything. +func TestInitStore_EmptyCollectionIsFine(t *testing.T) { + provider := storage.NewMemoryProvider() + stickerColl := provider.Collection(CollectionName) + systemColl := provider.Collection(systemstate.CollectionName) + + if err := InitStore(context.Background(), stickerColl, systemColl); err != nil { + t.Fatalf("InitStore on an empty collection: %v", err) + } + + rec, found, err := systemstate.New(systemColl).Get(context.Background(), legacyPackCleanupMarkerKey) + if err != nil || !found { + t.Fatalf("marker: found=%v err=%v", found, err) + } + if rec.Count != 0 { + t.Errorf("count = %d, want 0", rec.Count) + } +} + +// The module itself must keep using none of this: if a future edit gives the +// factory a store, the cleanup above would start deleting live data. +func TestNew_UsesNoStorage(t *testing.T) { + provider := storage.NewMemoryProvider() + coll := provider.Collection(CollectionName) + seedLegacy(t, coll, "sentinel") + + mod := New(modules.Deps{Store: coll}) + if len(mod.Commands) != 1 || mod.Commands[0].Name != "addsticker" { + t.Fatalf("module commands = %+v, want only /addsticker", mod.Commands) + } + if got := remainingKeys(t, coll); len(got) != 1 { + t.Errorf("factory touched storage; remaining = %v", got) + } +}