A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

track the jetstream cursor

Signed-off-by: Will Andrews <did:plc:dadhhalkfcq3gucaq25hjqon>

+90 -3
+24 -1
consumer.go
··· 34 34 logger: logger, 35 35 handler: handler{ 36 36 store: store, 37 + did: did, 37 38 }, 38 39 } 39 40 } ··· 47 48 return fmt.Errorf("failed to create client: %w", err) 48 49 } 49 50 50 - cursor := time.Now().Add(1 * -time.Minute).UnixMicro() 51 + cursor, err := c.handler.store.GetCursor(ctx, c.handler.did) 52 + // if error or not found set to be around the time this app was create so that it starts from the begining 53 + // of when the type of records were first created 54 + if err != nil || cursor == 0 { 55 + cursor = time.Date(2025, time.October, 5, 12, 0, 0, 0, time.UTC).UnixMicro() 56 + } 57 + 58 + slog.Info("starting from cursor", "time", time.UnixMicro(cursor), "cursor", cursor) 51 59 52 60 if err := client.ConnectAndRead(ctx, &cursor); err != nil { 53 61 return fmt.Errorf("connect and read: %w", err) ··· 60 68 type HandlerStore interface { 61 69 CreateURL(id, url, did, originHost string, createdAt int64) error 62 70 DeleteURL(id, did string) error 71 + SaveCursor(ctx context.Context, did string, cursor int64) error 72 + GetCursor(ctx context.Context, did string) (int64, error) 63 73 } 64 74 65 75 type handler struct { 66 76 store HandlerStore 77 + did string 67 78 } 68 79 69 80 func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error { 81 + if event == nil { 82 + return nil 83 + } 84 + 85 + defer func() { 86 + err := h.store.SaveCursor(ctx, h.did, event.TimeUS) 87 + if err != nil { 88 + slog.Error("failed to save cursor", "error", err) 89 + } 90 + }() 70 91 if event.Commit == nil { 71 92 return nil 72 93 } 94 + 95 + slog.Info("handle event") 73 96 74 97 switch event.Commit.Operation { 75 98 case models.CommitOperationCreate:
+4
database/database.go
··· 46 46 if err != nil { 47 47 return nil, fmt.Errorf("creating status table: %w", err) 48 48 } 49 + err = createJetstreamTable(db) 50 + if err != nil { 51 + return nil, fmt.Errorf("creating jetstream table: %w", err) 52 + } 49 53 50 54 return &DB{db: db}, nil 51 55 }
+59
database/jetstream_cursor.go
··· 1 + package database 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "log/slog" 8 + ) 9 + 10 + func createJetstreamTable(db *sql.DB) error { 11 + createJetstreamTableSQL := `CREATE TABLE IF NOT EXISTS jetstream ( 12 + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, 13 + "did" TEXT, 14 + "cursor" INTEGER, 15 + UNIQUE(did) 16 + );` 17 + 18 + slog.Info("Create jetstream table...") 19 + statement, err := db.Prepare(createJetstreamTableSQL) 20 + if err != nil { 21 + return fmt.Errorf("prepare DB statement to create jetstream table: %w", err) 22 + } 23 + _, err = statement.Exec() 24 + if err != nil { 25 + return fmt.Errorf("exec sql statement to create jetstream table: %w", err) 26 + } 27 + slog.Info("jetstream table created") 28 + 29 + return nil 30 + } 31 + 32 + func (d *DB) SaveCursor(ctx context.Context, did string, cursor int64) error { 33 + sql := `INSERT INTO jetstream (did, cursor) VALUES (?, ?) ON CONFLICT(did) DO UPDATE SET cursor = ?;` 34 + _, err := d.db.Exec(sql, did, cursor, cursor) 35 + if err != nil { 36 + return fmt.Errorf("exec insert or update cursor: %w", err) 37 + } 38 + 39 + return nil 40 + } 41 + 42 + func (d *DB) GetCursor(ctx context.Context, did string) (int64, error) { 43 + sql := "SELECT cursor FROM jetstream where did = ?;" 44 + rows, err := d.db.Query(sql, did) 45 + if err != nil { 46 + return 0, fmt.Errorf("run query to get cursor: %w", err) 47 + } 48 + defer rows.Close() 49 + 50 + cursor := 0 51 + for rows.Next() { 52 + if err := rows.Scan(&cursor); err != nil { 53 + return 0, fmt.Errorf("scan row: %w", err) 54 + } 55 + 56 + return int64(cursor), nil 57 + } 58 + return 0, fmt.Errorf("not found") 59 + }
+3 -2
docker-compose.yaml
··· 1 1 services: 2 - knot: 2 + at-shorter: 3 + platform: linux/amd64 3 4 container_name: at-shorter 4 5 image: willdot/at-shorter:latest 5 6 environment: 6 - ENV_LOCATION: "/data/at-shorter.env" 7 + ENV_LOCATION: "/app/data/at-shorter.env" 7 8 volumes: 8 9 - ./data:/app/data 9 10 ports: