chore(sticker): drop the retired per-user pack records at startup

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.
This commit is contained in:
tiennm99 committed 2026-09-04 11:37:29 +07:00
1 parent be91d2eb41
commit b903d14fc2
4 files changed
+262

No files matched your search

+17
View File
@@ -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 {
+9
View File
@@ -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 |
+90
View File
@@ -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
}
+146
View File
@@ -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)
}
}